AndroidのThemeとStyle、本当は活用したいんだけどよくわからないので、どうしても後回しになって理解が進まずにいるって人多いと思います。
少なくとも僕が過去に関わったプロジェクトは、どれも活用までは至っていませんでした。
今回は、AndroidのTheme/Styleを雰囲気で理解している…というのを脱却するためにまとめました。
ThemeとStyle
Themeは変数の集合、StyleはAttributeの集合です。
ThemeはTheme
、ThemeOverlay
が当てはまり、
Styleは、style
、TextAppearance
、ShapeAppearance
が当てはまります。
ただし、これらの役割は定義で決まるものではなく、利用するときに決まるため、どれも区別なく<style>
タグによって定義されます。
(この、役割が利用する側によって決まるというのが、theme/styleの管理を難しくしているように思う…)
スタイルの継承
<style>
タグで定義するものは、継承関係をもたせることができます。
継承には明示的継承、暗黙的継承の2種類があり、同時に利用することはできません。
継承と書いていますが、型があるわけではないので、実際は定義の部分的な変更をする際に利用されるものです。
明示的継承
<style>
タグのparent
属性で、継承元のスタイルリソースを指定します。
<style name="YourApp.Theme" parent="Theme.MaterialComponents.Light.NoActionBar" />
明示的継承は、暗黙的継承よりも優先されます。
parent
には、他のスタイルまたは空を指定できます。
空のユースケースについては後述します。
暗黙的継承
スタイル名を.
(ドット)で区切ることで、スタイルが継承されます。
このとき、parent
属性は定義してはいけません。(明示的継承が優先されるため)
<style name="YourButtonStyle" />
<style name="YourButtonStyle.Outlined" />
このとき、.
以前のスタイルは定義されているものである必要があります。
命名と継承
暗黙的継承は、.
で繋げることで表現されると説明しました。
しかし、.
で繋げていても、暗黙的継承を無効にすることができます。
それが、parent
属性に空を指定する方法です。
<style name="YourApp.Widget.TextView" parent=""/>
また、これを利用することで、存在しないスタイルを.
で繋いだ名前を作ることができます。
<!-- YourApp.Widgetが定義されていないため、エラーになる -->
<style name="YourApp.Widget.TextView"/>
<style name="YourApp.Widget.TextView" parent=""/>
「parent
が空」と「parent
定義なし」は明確に区別されているので、注意してください。
Theme
Themeは変数の集合です。
?attr/colorPrimary
のような参照を書いた場合に、Themeから該当する変数の値を解決します。
<View
android:background="?colorPrimary"
... />
<style name="YourApp.Theme" parent="...">
<item name="colorPrimary">#FF0000</item>
</style>
Themeに定義できる変数は、<attr>
タグで定義されるもの全てです。
これにはdeclare-styleable
内のattr
も含んでいます。
変数には型のような概念があり、<attr>
タグのformat
属性で指定されています。
これは変数の実態が何を表現するものかを示しています。(string, boolean, color, etc...)
また、同名の変数の型違いは定義できません。
Theme Attributesの参照
XMLで参照する場合
?attr/
または?
で参照できます。
<View
android:background="?attr/colorPrimary"
android:foreground="?selectableItemBackground"
... />
Androidの定義を参照する場合は、?android:attr/
または?android:
となります。
<TextView
android:textColor="?android:textColorSecondary"
... />
レイアウトを例にしましたが、Androidのリソース内であればだいたいどこでも使えます。
ただし、Android 5.0以下の場合は、ShapeDrawableやStateList系リソースで参照できないバグがあるので、工夫が必要です。
コードで参照する場合
TypedValue typedValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.colorPrimary, typedValue, true);
typedValue.data;
TypedValue().apply {
context.theme.resolveAttribute(R.attr.colorPrimary, this)
}.data
TypedValue.data
が値を持っていますが、上で書いたようにTheme Attributesは値が何を示しているのか区別されているので、それぞれ取得方法が変わります。
これは、TypedValue.type
で判別可能ですが、これによって分岐をする必要があります。
ここでは省きますが、そこそこめんどくさいので頑張ってください(丸投げ)
ちなみに、TypedValue.resourceId
はTheme AttributesにリソースIDが指定されている場合だけ使えます。
Style
StyleはXML Attributeの値のプリセットです。
Themeが変数の値を扱っていたのに対して、StyleはAttributeの直接の値を扱います。
<View
style="@style/ViewStyle"
... />
<style name="ViewStyle" parent="...">
<item "android:background">?colorPrimary</item>
</style>
View Style
全てのViewは1つのスタイルリソースを当てることができます。
これはstyle
属性で指定します。
<View
style="@style/YourApp.Style"
... />
style
属性を明示的に指定しない場合でも、スタイルリソースは当たっています。これをデフォルトスタイルと呼んでいます。
このデフォルトスタイルには動的なデフォルトと静的なデフォルトの2つが存在し、それぞれ、defStyleAttr
、defStyleRes
という変数名でViewのコンストラクタで確認できます。
class TextView {
TextView(
Context context,
@Nullable AttributeSet attrs,
int defStyleAttr, // Dynamic Default
int defStyleRes // Static Default
) {
...
}
}
どちらもリソースIDを要求しますが、defStyleAttr
は@AttrRes
、defStyleRes
は@StyleRes
という違いがあります。
(アノテーションが指定されていないけど)
defStyleAttr
desStyleAttr
によるデフォルトスタイルは、参照するリソースを自由に設定できます。
リソース変数はThemeに記述し、参照する値はContextが持つThemeに依存します。
そのため、デフォルトスタイルを解決するまでに、やや遠回りに見えるフローを辿っています。
class TextView {
TextView(Context context, @Nullable AttributeSet attrs) {
this(
context, // ← Theme source `context.getTheme()`
attrs,
com.android.internal.R.attr.textViewStyle // Reference Variable Name in Theme for defaultStyle
);
}
TextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
...
}
}
<style name="Theme.WithContext" parent="...">
<item name="android:textViewStyle">@style/YourApp.Widget.TextView</item>
</style>
<style name="YourApp.Widget.TextView" parent="...">
<!-- Reference Attributes here -->
</style>
defStyleRes
defStyleRes
はシンプルに、指定されたスタイルリソースを直接参照します。
defStyleRes
は基本的に変更できません。
ただし、これはあくまでもコンポーネントがサードパーティによって提供されている場合を指しています。
自作Viewを作る場合、この制約を容易に破れるため、わざわざdefStyleAttr
を使わず、defStyleRes
を使うケースも多くみられます。
しかし、あくまで自身のプロジェクト内に閉じているから通じるのであって、カスタムViewをライブラリとして公開する場合はdefStyleAttr
もキチンと実装しておくことを推奨します。
Layout Attributes
Viewの置かれているViewGroupが読み取る属性です。
厳密には、ViewGroup.LayoutParams
が読み取ります。
この属性は基本的にlayout_
を前置している属性名となっています。
(layout_width
, layout_height
, layout_gravity
, layout_constraintStart_toStartOf
, etc...)
また、declare-styleable
に定義する際には、_Layout
を後置するのが一般的です。
(どこかに規則としてあるのかは調べていません。また、サードパーティ製のライブラリではこれに則していない場合があります)
親ViewGroupが読み取る属性であるため、本来style
に書くべきではないものです。
(記述できるし、普通に動きますが、混乱のもとなので可能な限り避けることをオススメします。)
View Attributes
View自身が読み取る属性です。
値の解決
1. View attrs
<View
android:minHeight="32dp"
... />
2. style attrs
<TextView
style="@style/YourApp.Widget.TextView"
... />
<style name="YourApp.Widget.TextView" parent="...">
<!-- Reference Attributes here -->
</style>
How does View resolve Theme?
Viewが参照するTheme変数の値は、以下の順でThemeリソースを辿って解決されます。
1. ThemeOverlay
<TextView
app:theme="@style/YourApp.ThemeOverlay"
... />
ThemeをView階層で変更するための属性です。
自分とその子Viewに影響を与えます。
この属性はAppCompatActivity
によってフックすることで実現されており、全てのViewに適用されます。
ただし、app:theme
はデフォルトスタイルで指定しても有効になりません。
また、Android Studioのプレビューは、ThemeOverlayを十分に反映してくれません。
おかしいと思ったら、tools:theme
も書いておくと改善すると思います。
2. MaterialThemeOverlay
<style name="YourApp.Theme" parent="...">
<item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
<style name="YourApp.Widget.Button" parent="...">
<item name="materialThemeOverlay">@style/YourApp.ThemeOverlay.Button</item>
</style>
<style name="YourApp.ThemeOverlay.Button" parent="...">
<!-- Reference Theme Attributes here -->
</style>
materialThemeOverlay
はThemeOverlayがデフォルトスタイルで有効にならないことを解決するために生まれたものです。
MaterialComponentsで提供されているViewのみがこの属性に対応しています。(AppCompatActivityとは別のアプローチで実現しているため)
3. Context Theme
親ViewGroupがThemeOverlayやMaterialThemeOverlayを設定している場合、そのThemeにフォールバックされます。
その後、ActivityContext、そしてApplicationContextのThemeとなります。
これはViewに渡すContextに依存しているため、特にViewをコードで生成している場合、渡したContextによっては必ずしもこの振る舞いを取らなくなります。(ViewにApplicationContextを渡すなど)
そのため、コードでViewを生成する場合、与えるContextインスタンスには注意する必要があります。
Themeのフォールバック
[View]
themeOverlay
↓
materialThemeOverlay
↓
[Parent View...]
themeOverlay
↓
materialThemeOverlay
↓
[Manifest]
Activity Theme
↓
Application Theme
TextAppearance
TextAppearance
はテキストスタイル属性のみを扱うstyleです。
対応している属性はTextViewの実装次第となっています。
当初はおそらく、text
で始まる属性名はTextAppearanceに対応し、
また、TextAppearanceに対応するものはtext
で始まるようにしようとしていたような名残があります。(推察)
しかし、現在はこの制約は特に守られていません。
lineHeight
、lineSpacingExtra
、lineSpacingMultiplier
はテキストのスタイルのはずですが、text
で始まっていないため、TextAppearanceに対応していません。
このうち、lineHeight
については、MaterialTextView
であればTextAppearance
で設定できます。(ThemeのtextAppearanceLineHeightEnabled
の値でオプトアウトできます)
値の解決
1. View text's attrs
<TextView
android:textColor="@color/yourTextColor"
... />
2. View textAppearance
style-resource text's attrs
<TextView
android:textAppearance="@style/YourApp.TextAppearance"
... />
<style name="YourApp.TextAppearance" parent="...">
<!-- Reference text's Attributes here -->
</style>
3. style text's attrs
<TextView
style="@style/YourApp.Widget.TextView"
... />
<style name="YourApp.Widget.TextView" parent="...">
<!-- Reference text's Attributes here -->
</style>
4. style textAppearance
style-resource text's attrs
<TextView
style="@style/YourApp.Widget.TextView"
... />
<style name="YourApp.Widget.TextView" parent="...">
<item name="android:textAppearance">@style/YourApp.TextAppearance</item>
</style>
<style name="YourApp.TextAppearance" parent="...">
<!-- Reference text's Attributes here -->
</style>
ShapeAppearance
ShapeAppearance
は、MaterialComponentsで追加されたスタイル定義です。ざっくりいうとTextAppearanceのShape版です。
ここでいうShapeはマテリアルのカタチを決める要素のこと。(ShapeDrawableとは関係ないです)
値の解決
1. View shape's attrs
<Button
app:cornerFamily="cut"
... />
2. View shapeAppearanceOverlay
style-resource shape's attrs
<Button
app:shapeAppearanceOverlay="@style/YourApp.ShapeAppearanceOverlay.Button"
.../>
<style name="YourApp.ShapeAppearanceOverlay.Button" parent="...">
<!-- Reference shape's Attributes here -->
</style>
3. View shapeAppearance
style-resource shape's attrs
<Button
app:shapeAppearance="@style/YourApp.ShapeAppearance.Button"
... />
<style name="YourApp.ShapeAppearance.Button" parent="...">
<!-- Reference shape's attributes here -->
</style>
4. resolve by style
1-3を同様にstyle
で解決します。
?shapeAppearanceSmall/Medium/LargeComponent
ShapeAppearanceはSmall, Medium, Largeの3つのグループの区分けされています。
Shapeに対応しているMaterialComponentsのViewは、このいずれかのTheme AttributesをshapeAppearance
のデフォルト値として設定しています。
Buttonを例にします。
Buttonはデフォルトで、?shapeAppearanceSmallComponents
が参照しています。
これはレイアウトで書くと以下のような状態です。(実際はデフォルトスタイルで設定されています)
<Button
app:shapeAppearance="?shapeAppearanceSmallComponents"
... />
ボタンの角をCut
にしたいため、?shapeAppearanceSmallComponents
で対応してみます。
<style name="YourApp.Theme" parent="...">
<item name="shapeAppearanceSmallComponents">@style/YourApp.ShapeAppearance.Small</item>
</style>
<style name="YourApp.ShapeAppearance.Small" parent="...">
<item name="cornerFamily">cut</item>
</style>
?shapeAppearanceSmallComponents
を変更すると、これを参照する別のコンポーネントにも影響を及ぼします。
例えば、他にSmallを参照しているものにはFloatingActionButton
やChip
といったコンポーネントが存在します。
そのため、?shapeAppearanceSmallComponents
をcut
に変更すると、FabやChipにも反映されます。
このように、Shapeを決まったサイズでグループ化する方法が?shapeAppearanceSmallComponents
です。
どれがどのコンポーネントに影響を与えるのかは、マテリアルデザインのドキュメントにあります。
shapeAppearanceとshapeAppearanceOverlay
Shapeに対応しているコンポーネントのshapeAppearance
は、デフォルトで?shapeAppearanceSmallComponents
、?shapeAppearanceMediumComponents
、?shapeAppearanceLargeComponents
のいずれかを参照しています。
先ほども書いたように、Button
やFloatingActionButton
であればSmallが参照されています。
一方でShapeを扱うViewには、shapeAppearance
とは別に、shapeAppearanceOverlay
属性が設定できます。
では、この2つはどのように使い分けるのでしょうか。
ButtonとFabはそれぞれ以下のように定義されています。
Button
<style name="Widget.MaterialComponents.Button" parent="Widget.AppCompat.Button">
...
<item name="shapeAppearance">?attr/shapeAppearanceSmallComponent</item>
</style>
Fab
<style name="Widget.MaterialComponents.FloatingActionButton" parent="Widget.Design.FloatingActionButton">
...
<item name="shapeAppearance">?attr/shapeAppearanceSmallComponent</item>
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.MaterialComponents.FloatingActionButton</item>
</style>
<style name="ShapeAppearanceOverlay.MaterialComponents.FloatingActionButton" parent="">
<item name="cornerSize">50%</item>
</style>
Buttonの定義に対して、FabではshapeAppearanceOverlay
でcornerSize
を50%
に設定しています。
この定義から、FabはshapeAppearance
ではSmallComponentグループとしてカタチを踏襲し、shapeAppearanceOverlay
でShapeの大きさを変更しているということがわかります。
shapeAppearanceOverlay
は、コンポーネントのグループとしての統一性を引き継いだ上で、部分的に値を書き換える場合に利用するということになります。
仮に、ButtonをSmallComponent
グループから外したい場合は、shapeAppearance
を書き換えてしまえばいいということです。
逆になんでもかんでもshapeAppearance
の値変更でやっていると、思いもよらぬところで苦労することになるので、スタイルをどう定義すべきかは慎重に選択しましょう。
Q&A
Q. テキストスタイルはStyleとTextAppearanceのどっちを使うのがいいの?
TextViewならどっちでもいい。それ以外はtextAppearance
。
style
はテキスト以外の属性も扱えることから、用途が広いです。
一方で1つしか設定できないため、テキストに関してはなるべくtextAppearance
を使うことで、style
との複合して使う余地を設けておいたほうがいいです。
Q. TextAppearanceと?attr/textAppearance
どっちを参照したほうがいいの?
理想的なのは?textAppearanceBody1
を参照すること。
スタイルを切り替えるという手段がTheme層に増えるので、よりスタイル管理の幅が生まれます。
スタイルを直接参照すると、styleの代替リソースを大量に定義することになり、後々めんどくさくなる可能性があります。
これは他のリソースにも言えます。
Q. Widget.MaterialComponent.Buttonなどを直接使ってもいいの?
使ってもいいけど、経験則的にはやめたほうがいいです。あとで整理しようとしたときに苦労します。
定義の内容を変更しない場合であっても、とりあえず自前でTheme/Styleを作っておくほうがよい。
<style name="YourApp.Widget.Button" parent="Widget.MaterialComponents.Button">
<!-- No override attributes -->
</style>
<style name="YourApp.Theme" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
<Button
style="@style/YourApp.Widget.Button"
... />
<application android:theme="@style/YourApp.Theme">
</application>
Q. スタイルの命名規則は?
特に決まってはいませんが、最初に書いたように定義を見ても区別できないので、命名規則は設けておいたほうがいいです。
- ThemeOverlay, MaterialThemeOverlay
*.ThemeOverlay.*
- ex.
ThemeOverlay.Dark
- ex.
YourApp.ThemeOverlay.Dark
- View
*.Widget.*
- ex.
YourApp.Widget.Button
- TextAppearance
*.TextAppearance.*
- ex.
TextAppearance.Body1
- ex.
YourApp.TextAppearance.Body1
- ShapeAppearance
*.ShapeAppearance.*
- ex.
ShapeAppearance.Button.Cut
- ex.
YourApp.ShapeAppearance.Button.Cut
- ShapeAppearanceOverlay
*.ShapeAppearanceOverlay.*
- ex.
ShapeAppearanceOverlay.Button.LargeCorner
- ex.
YourApp.ShapeAppearanceOverlay.Button.LargeCorner
結局スタイル定義の最適解ってなに?
ありません。
スタイル定義の目標はデザイナーの思惑に応えることにあります。
これは、デザイナーの提示したデザインを実装するという意味ではありません。
デザイナーがデザインをどう管理しようとしているかをアプリのスタイル定義に反映させるということです。
例えば、ボタンの色をブランドカラーにしたとします。
この場合、ブランドカラーを変更したら、対象となる全てのボタンに反映されるといったことを期待されています。
この例を実現する方法は様々あります。カラーパレットを管理するといった程度でも実現できますね。
しかし、どのレイヤーでどの程度の修正ができ、どの範囲に反映されるのかというのはデザイナー自身も考えて設計しています。
そのため、スタイル定義は手を動かす前に、まずデザイナーの思惑を確認するようにするのが大事です。
そして、エンジニアはデザイナーの思惑を実現できるように、実装面の理解を深めておくことが重要です。
このへん本来は、デザイナー自身がやれることが望ましいですが、多くのデザイナーはAndroidのスタイルをいじれないので、エンジニアが頑張りましょう!