Style & Theme 的那一兩件事情
本篇文章基於本影片所轉譯撰寫,有興趣了解更多的人可以參考這影片:
Style 是什麼? Theme 又是什麼?
寫 Android App,除了模組邏輯一堆零零叩叩的東西,還有 View 的 Layout 要寫,那這時候可能就會使用 Xml。嘛,除非是很動態的 View ,不然一般都會傾向使用 xml 去拉 UI ,因為可以視覺化,多數清況下可以先行預見結果對開發是有幫助的
只是有時候,我們會遇到這種很多 View 的情況
上頭有八個字,第一次,感覺這字的顏色不對,換掉好了,於是乎,我們在這八個 TextView 上換了八個 textColor
這時看了看,覺得換成綠色好惹,於是乎,我們又換了八次 textColor
看了看,覺得可以了,想了一下,其他還有大概十個左右的 layout 要改…總覺得這樣有點辛苦,有沒有辛苦一次,後頭改一個地方的方法?
那就是 style 了:
<resources>
<style name="TextViewStyle" parent="Widget.AppCompat.TextView">
<item name="android:textColor">@android:color/black</item>
</style>
</resources>
那在 View 的 layout 則會改成這樣:
<TextView
style="@style/TextViewStyle"
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
/>
這樣只要套上這個 style 的 textView ,只要更動 style ,就會全部更動惹,完美,めでたし、めでたし…
那這邊就是大家都知道的 style 了,那 theme 呢?
style 可以翻作是風格,那 theme 可以翻作是主題,不過把他們兩個直接翻譯成中文好像對瞭解這兩個東西的幫助不大,
那首先呢? Style ,你可以把它當作一個用於 View attribute 對應其 value 的map 表;而 theme 呢?則是 theme attribute 對應其 value 的 map 表。
這樣講其實還是有點抽象,那可以拿上頭那個 TextViewStyle 來加幾行說明:
<style name="TextViewStyle" parent="Widget.AppCompat.TextView">
<item name="android:textColor">@android:color/black</item>
<item name="android:background">@drawable/bg_text_view</item>
<item name="android:paddingBottom">5dp</item>
<item name="android:paddingTop">5dp</item>
</style>
可以看到 item name 裡頭對應的都是 view 的 attribute ,而 value 則是可能來自 resource 的 drawable 或者是 color ,也有可能是 dp sp 之類的常數。
那 theme 呢?
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
跟 style 很像,而且 tag 也叫 style ,但顯然內容物不同。可以看到 Them 裡頭的 item 不是 view 的 attribute ,裡頭的 key 是被稱為 theme attribute 的東西,那 theme attribute 到底什麼鬼東西?
這裡來看看常見的 colorPrimary,往下追可以在各式各樣的 theme (例如 Theme.AppCompat.Light )看到這個東西:
<attr format="color" name="colorPrimary"/>
那把它移到 layout 上,他看起來會是這樣:
?attr/colorPrimary
是不是很眼熟?一些情況下我們會這樣去用它:
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:textColor="?attr/colorPrimary"/>
那顯然 ?attr/colorPrimary
對應的就是 <item name=”colorPrimary”>@color/colorPrimary</item>
這項設置。
要注意的是 ?
代表的是當前的 theme (Kotlin 寫多了還以為是 nullable),雖然是這樣講,但是我並不知道有沒有直接用其他 Theme 的 attribute 的語法,不過如果要在一個 View 上用另一個 theme 的 attribute 的話,這樣寫就好:
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:theme="@style/AppTheme2"
android:textColor="?colorPrimary"/>
不過一般是不會這樣寫就是了。
了解這點後,很酷的是,如果需要更換 Layout 的配色和表皮,例如說配合會員等級,初級、高級、VIP,只要更換 Theme 就行了。
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@android:color/black</item>
<item name="android:textAppearance">5sp</item>
</style>
<style name="AppTheme2" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowBackground">@android:color/white</item>
<item name="android:textAppearance">5sp</item>
</style>
旁邊同事:這從 4.0 就有了,是存在感很低的傢伙呢
總而言之,Style 是以特定的 View 為目標的;而 Theme 則是以改變一整個 layout 和或者是一整個 Application 為目標的。
那依照上面所說,我們拿常見的 ViewGroup 層級來說,如果 ViewGroup 用的 Style
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
style="@style/Widget.Demo.CustomViewStyle"
tools:context=".MainActivity">
//ignore
那麼結果會是只有該 ViewGroup 套用該效果
那如果再 ViewGroup 使用的是 android:theme
android:theme="@style/Theme.Demo.AppTheme"
那麼 ViewGroup 的 Child 都會套用該 theme 的效果,更甚是,manifest 裏頭的 activity 也有個 android theme 可以用,那就是這整個 activity 都會用這個 theme 了。
那這邊要注意的是,因為 Preview 是吃 default 設定,直接改在 xml 上 preview 的 Layout 不會有變化,要直到 Runtime 才會變更,除非手動去切換 Preview 上面的 theme。
另外, Theme 用在 Style 的 Attribute 上沒有任何用處,反過來 Style 用在 Theme 也沒有任何用處,這點需要注意。
那麼 theme 實際上都是繼承自原生裏頭有的東西,例如 我的 AppThem 繼承自 Theme.AppCompat.Light,那 Theme.AppCompat.Light 是繼承自 Base.Theme.AppCompat.Light.DarkActionBar ,Base.Theme.AppCompat.Light.DarkActionBar 則是繼承自 Base.Theme.AppCompat.Light (以下略),如此繼承下去,形成一個繼承的 Stack,那顯然地,會以最後繼承者為優先,也就是假設說 AppThem 和 Theme.AppCompat.Light 都有 colorPrimary,那會以 AppThem 的 colorPrimary 為主,惱人的問題是,如果全部都是那也都還好,但會有情況是一些 Layout 會需要例外,例如AppTheme 的 colorPrimary 是綠色,但是某個 UI 裏頭我的 TextView 要使用的顏色是紅色,難不成我又得一個一個改那這裡有一個選項是 ThemeOvelay
<style name="ThemeOverlay.Demo.AppTheme" parent="">
<item name="colorPrimary">@android:color/holo_orange_light</item>
</style>
ThemeOverlay 的特色就是,不用繼承任何父項目,但他只會改我想改的 theme attribute ,這樣可以很精美的達到上頭的需求了。
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:theme="@style/ThemeOverlay.Demo.AppTheme"
android:textColor="?colorPrimary"
android:background="?colorPrimaryDark"
/>
以這個例子來說 textColor 會吃 ThemeOverlay.Demo.AppTheme 設定的 colorPrimary ,而 background 則會吃 Application 設定的 Theme 的 colorPrimaryDark 。
Color State List
按鈕點擊事件的時候,常常會有需求要說按鈕觸控反應,白話就是按下去按鈕會變色,這樣,這種我們最常用的方式除了去覆寫 onTouchListener 外,就是生一個 selector
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorPrimaryDark" android:state_pressed="true" />
<item android:drawable="?attr/colorPrimary" />
</selector>
這很常見,不過有一個觀念是:
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?attr/colorPrimary" />
<item android:drawable="?attr/colorPrimaryDark" android:state_pressed="true" />
</selector>
這個情況下點擊顏色不會有任何變換,會一直是 colorPrimary,因為 item 沒有標記任何狀態,代表它可以是任何狀態,包括點擊事件或是 enable 、focused 之類的。
那Android 裏頭的 drawable Xml 一個特性是 first match ,那他找到第一個狀態便是合乎任何項目的無狀態,那自然就永遠是 colorPrimary。
那接續這個用法,假如專案中有很多 Theme ,而我們為了很多的 Theme 有幾個顏色會有 Alpha 值變化,那很單尬的是,為了這些 Alpha 值,會有 Hen 多的 color setting ,那說起來這究竟有沒有需要呢?影片中提出一個解決方案 — 可以用 state list 去解決
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.5" android:color="?attr/colorPrimary" />
</selector>
然後在 View 這樣去使用:
<TextView
android:id="@+id/textView8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
android:textColor="@color/bg_button_color" />
這樣就不用定義這麼多的 color 了。那這邊會有個小陷阱,如果今天定義的 Attribute 不是 textColor 而是 background ,API 28 以下的版本會噴 Exception。
理由其實很簡單,background 需要將這個 color 當成一個 drawable,並打包成一個 ColorDrawable,並在之後將其畫上 Canvas ,那因為 xml 檔案並沒有告訴 Drawable 是什麼,所以會出錯,此外,就算使用了 drawable ,因為 drawable 並不是 stateful ,所以那個 alpha 並不會有任何作用。API 28 才有針對這個設定做處理,Android 老毛病,版本破碎的問題。
那這其實也並非沒有解決方案,這邊有一個方法,在 res/drawable 裡頭加一個 shape
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#000" />
</shape>
並把設定的 color 弄成 backgroundTint
<TextView
android:backgroundTint="@color/bg_button_color" />
backgroundTint 也有版本破碎的問題,需要到 API 21 以上才支援,真的碰到這種情況就用 app:backgroundTint 就好了。
Material Color system
影片裡沒提及版本,我自己嘗試其實是在 material:1.1.0 以上才有,目前的版本是 ‘com.google.android.material:material:1.1.0-beta01’ ,有興趣可以試試。
Material Theme 提供了很多的 color attribute 使用:
- colorPrimary : 顧名思義,就是主要的顏色,這個通常指得是 App 本身產品的代表色,通常也是品牌的主要視覺色
- colorPrimaryVariant:主要顏色的變體,通常會從 colorPrimary 往較淡或較濃的色澤
- colorOnPrimary:字面意思就是主要顏色上頭的顏色,這個顏色通常使用在背景色是主要顏色的元件上頭(例如字樣 Label 、icon 等)
- colorSecondary:app 次要的品牌顏色,這些用於裝飾某些特定需要的 widget
- colorSecondaryVariant:次要顏色的變體,也就是次要顏色偏暗或偏亮的樣式
- colorOnSecondary:用於顯示於次要顏色上元件的顏色
- colorError:顯示錯誤的顏色 (最常見的就是紅色)
- colorOnError:在錯誤顏色上頭元件的顏色
- colorSurface:表層顏色(就是 Sheet 的顏色)
- colorOnSurface:在表層顏色上的的元件顏色
- android:colorBackground:最底的背景色
- colorOnBackground:用於對底背景色上頭的元件用的顏色
那麼利用這些屬性,搭配上面的那些技巧,可以組合出很棒的效果。
不過老實說,因為這些 Theme 有很多預設的樣式和顏色,所以要換的時候很多原本沒有的東西會多出來,需要注意。
Dark Theme
這是 Android 10 出來的新玩意,根據 Android Developer 上的說明, Dark Theme 的好處可能不止比較酷炫而已:
- 可以減少電池的使用量(這點是取決於手機的螢幕怎麼設計)
- 暗色系可以對於光線敏感的使用者更友善
- 對於在暗處使用手機的人更友善
暗色系的好處很多,那真的要開發上使用 Dark Theme 要怎麼做呢?
純粹在開發上使用的話,可以用這個方法去開 Dark Theme
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
這樣 App 就會預設開啟 Dark Theme 了。
那要怎麼新增 Dark Theme 呢?在設定 Theme 的部份繼承的 Parent 改成
<style name="Theme.Demo.AppTheme" parent="Theme.AppCompat.DayNight">
只是這個在 Color 的預設上面會比較少一點,要設定的顏色參數會比較多,嫌麻煩想用預設的話,Material 也有提供:
<style name="Theme.Demo.AppTheme" parent="Theme.MaterialComponents.DayNight">
這些都設定好了,系統會幫你 handle 好一切了…
Everything’s cool isn’t it?
嘛,系統畢竟沒這麼聰明,況且,顏色設定什麼都沒做,他就自己好其實也是很恐怖地,就好比 Auto gen 的 code 一樣,沒有把握他會不會在自產的過程中產生了自己應該知道而不知道的錯。
舉例來說,我這裡繼承了一個 Theme,在 Light mode 會長這樣:
切換成 Dark Theme 會變成這樣:
那顯然那個紫色的字會造成 App 的閱讀困難。儘管這些字其實它的顏色是使用 ?attr/colorSecondary
,但是對系統來說,我只有告訴他 colorSecondary 是紫色,並沒有告訴系統說在 Dark Theme 需要的是什麼顏色,所以很自然地,再切換 Dark Theme 後,就顯示紫色了。
那要怎麼告訴系統我在 Dark Theme 要顯示什麼顏色呢?其實很簡單 — 新增一個資料夾叫 values-night,而裡頭加一個 Resource file ,叫 colors
那最後會變成這樣 Light Mode 的 Color:
- values/colors<color name="color_purple">#3700B3</color>
而 Dark Mode 的 Color:
- values-night/colors<color name="color_purple">#e1bee7</color>
這樣看起來好多。
那其實 values 可以分資料夾的話,裝在裡頭的東西都可以分了,包括 string,不過一般是不會這麼搞剛就是了。
不過這邊倒是可以把 Theme 分開來寫,類似這樣:
- values/colors<style name="Theme.Demo.AppTheme" parent="Theme.MaterialComponents.DayNight">
<item name="colorPrimary">@color/colorPrimary</item>
</style>
—
- values-night/colors<style name="Theme.Demo.AppTheme" parent="Theme.MaterialComponents.DayNight">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimaryDark</item>
</style>
那具體怎麼做,還是要看專案怎麼去規劃,這顯然沒有一個絕對正確的方法。
那當然這影片裡有介紹到很多命名和顏色分類的方法,我上面那個方法會導致混亂並不太好,因為這又是另一個故事,我就不贅述了。
結語
影片長達 40 多分鐘,這邊我挑幾個我比較有感覺的來說,那其實文章內容已經和影片大相徑庭了。影片的內容很多,包括一些命名的介紹,有興趣了解更多的一定要看看影片本身。
應該有不少的 Android 開發者與我相同,長年以來搞不清楚 Style 和 Theme 的差別,那看了這影片至少可以理解這兩者的差異,讓人能夠理解,我們能利用 Style 去管控多個 View ,同樣,也能用 Theme 去對整個 App 做系統化的管理,就像影片裡所說的「別用 Style 打 Theme 的仗了」(“Don’t bring a style to theme fight”)
如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。