LoginSignup
88
68

More than 3 years have passed since last update.

AndroidのTheme/Styleを理解する

Last updated at Posted at 2019-12-16

AndroidのThemeとStyle、本当は活用したいんだけどよくわからないので、どうしても後回しになって理解が進まずにいるって人多いと思います。
少なくとも僕が過去に関わったプロジェクトは、どれも活用までは至っていませんでした。

今回は、AndroidのTheme/Styleを雰囲気で理解している…というのを脱却するためにまとめました。

ThemeとStyle

Themeは変数の集合、StyleはAttributeの集合です。

ThemeはThemeThemeOverlayが当てはまり、
Styleは、styleTextAppearanceShapeAppearanceが当てはまります。

ただし、これらの役割は定義で決まるものではなく、利用するときに決まるため、どれも区別なく<style>タグによって定義されます。

(この、役割が利用する側によって決まるというのが、theme/styleの管理を難しくしているように思う…)

スタイルの継承

<style>タグで定義するものは、継承関係をもたせることができます。
継承には明示的継承暗黙的継承の2種類があり、同時に利用することはできません。

継承と書いていますが、型があるわけではないので、実際は定義の部分的な変更をする際に利用されるものです。

明示的継承

<style>タグのparent属性で、継承元のスタイルリソースを指定します。

themes.xml
<style name="YourApp.Theme" parent="Theme.MaterialComponents.Light.NoActionBar" />

明示的継承は、暗黙的継承よりも優先されます。

parentには、他のスタイルまたはを指定できます。
空のユースケースについては後述します。

暗黙的継承

スタイル名を.(ドット)で区切ることで、スタイルが継承されます。
このとき、parent属性は定義してはいけません。(明示的継承が優先されるため)

styles.xml
<style name="YourButtonStyle" />

<style name="YourButtonStyle.Outlined" />

このとき、.以前のスタイルは定義されているものである必要があります。

命名と継承

暗黙的継承は、.で繋げることで表現されると説明しました。
しかし、.で繋げていても、暗黙的継承を無効にすることができます。

それが、parent属性にを指定する方法です。

styles.xml
<style name="YourApp.Widget.TextView" parent=""/>

また、これを利用することで、存在しないスタイルを.で繋いだ名前を作ることができます。

invalidcase.xml
<!-- YourApp.Widgetが定義されていないため、エラーになる -->
<style name="YourApp.Widget.TextView"/>
styles.xml
<style name="YourApp.Widget.TextView" parent=""/>

parentが空」と「parent定義なし」は明確に区別されているので、注意してください。

Theme

Themeは変数の集合です。
?attr/colorPrimaryのような参照を書いた場合に、Themeから該当する変数の値を解決します。

layout.xml
<View
  android:background="?colorPrimary"
  ... />
styles.xml
<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/または?で参照できます。

layout.xml
<View
  android:background="?attr/colorPrimary"
  android:foreground="?selectableItemBackground"
  ... />

Androidの定義を参照する場合は、?android:attr/または?android:となります。

layout.xml
<TextView
  android:textColor="?android:textColorSecondary"
  ... />

レイアウトを例にしましたが、Androidのリソース内であればだいたいどこでも使えます。
ただし、Android 5.0以下の場合は、ShapeDrawableやStateList系リソースで参照できないバグがあるので、工夫が必要です。

コードで参照する場合

Sample.java
TypedValue typedValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.colorPrimary, typedValue, true);

typedValue.data;
Sample.kt
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の直接の値を扱います。

layout.xml
<View
  style="@style/ViewStyle"
  ... />
styles.xml
<style name="ViewStyle" parent="...">
  <item "android:background">?colorPrimary</item>
</style>

View Style

全てのViewは1つのスタイルリソースを当てることができます。
これはstyle属性で指定します。

layout.xml
<View
  style="@style/YourApp.Style"
  ... />

style属性を明示的に指定しない場合でも、スタイルリソースは当たっています。これをデフォルトスタイルと呼んでいます。
このデフォルトスタイルには動的なデフォルト静的なデフォルトの2つが存在し、それぞれ、defStyleAttrdefStyleResという変数名でViewのコンストラクタで確認できます。

TextView.java
class TextView {

  TextView(
    Context context,
    @Nullable AttributeSet attrs,
    int defStyleAttr,  // Dynamic Default
    int defStyleRes    // Static Default
  ) {
    ...
  }
}

どちらもリソースIDを要求しますが、defStyleAttr@AttrResdefStyleRes@StyleResという違いがあります。
(アノテーションが指定されていないけど)

defStyleAttr

desStyleAttrによるデフォルトスタイルは、参照するリソースを自由に設定できます
リソース変数はThemeに記述し、参照する値はContextが持つThemeに依存します。

そのため、デフォルトスタイルを解決するまでに、やや遠回りに見えるフローを辿っています。

TextView.java
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) {
    ...
  }
}
themes.xml
<style name="Theme.WithContext" parent="...">
  <item name="android:textViewStyle">@style/YourApp.Widget.TextView</item>
</style>
styles.xml
<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

layout.xml
<View
  android:minHeight="32dp"
  ... />

2. style attrs

layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
styles.xml
<style name="YourApp.Widget.TextView" parent="...">
  <!-- Reference Attributes here -->
</style>

How does View resolve Theme?

Viewが参照するTheme変数の値は、以下の順でThemeリソースを辿って解決されます。

1. ThemeOverlay

layout.xml
<TextView
  app:theme="@style/YourApp.ThemeOverlay"
  ... />

ThemeをView階層で変更するための属性です。
自分とその子Viewに影響を与えます。

この属性はAppCompatActivityによってフックすることで実現されており、全てのViewに適用されます。
ただし、app:themeデフォルトスタイルで指定しても有効になりません

また、Android Studioのプレビューは、ThemeOverlayを十分に反映してくれません。
おかしいと思ったら、tools:themeも書いておくと改善すると思います。

2. MaterialThemeOverlay

themes.xml
<style name="YourApp.Theme" parent="...">
  <item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
styles.xml
<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で始まるようにしようとしていたような名残があります。(推察)

しかし、現在はこの制約は特に守られていません。
lineHeightlineSpacingExtralineSpacingMultiplierはテキストのスタイルのはずですが、textで始まっていないため、TextAppearanceに対応していません。

このうち、lineHeightについては、MaterialTextViewであればTextAppearanceで設定できます。(ThemeのtextAppearanceLineHeightEnabledの値でオプトアウトできます)

値の解決

1. View text's attrs

layout.xml
<TextView
  android:textColor="@color/yourTextColor"
  ... />

2. View textAppearance style-resource text's attrs

layout.xml
<TextView
  android:textAppearance="@style/YourApp.TextAppearance"
  ... />
styles.xml
<style name="YourApp.TextAppearance" parent="...">
  <!-- Reference text's Attributes here -->
</style>

3. style text's attrs

layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
styles.xml
<style name="YourApp.Widget.TextView" parent="...">
  <!-- Reference text's Attributes here -->
</style>

4. style textAppearance style-resource text's attrs

layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
styles.xml
<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

layout.xml
<Button
  app:cornerFamily="cut"
  ... />

2. View shapeAppearanceOverlay style-resource shape's attrs

layout.xml
<Button
  app:shapeAppearanceOverlay="@style/YourApp.ShapeAppearanceOverlay.Button"
  .../>
styles.xml
<style name="YourApp.ShapeAppearanceOverlay.Button" parent="...">
  <!-- Reference shape's Attributes here -->
</style>

3. View shapeAppearance style-resource shape's attrs

layout.xml
<Button
  app:shapeAppearance="@style/YourApp.ShapeAppearance.Button"
  ... />
styles.xml
<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が参照しています。
これはレイアウトで書くと以下のような状態です。(実際はデフォルトスタイルで設定されています)

layout.xml
<Button
  app:shapeAppearance="?shapeAppearanceSmallComponents"
  ... />

ボタンの角をCutにしたいため、?shapeAppearanceSmallComponentsで対応してみます。

themes.xml
<style name="YourApp.Theme" parent="...">
  <item name="shapeAppearanceSmallComponents">@style/YourApp.ShapeAppearance.Small</item>
</style>
styles.xml
<style name="YourApp.ShapeAppearance.Small" parent="...">
  <item name="cornerFamily">cut</item>
</style>

?shapeAppearanceSmallComponentsを変更すると、これを参照する別のコンポーネントにも影響を及ぼします。
例えば、他にSmallを参照しているものにはFloatingActionButtonChipといったコンポーネントが存在します。
そのため、?shapeAppearanceSmallComponentscutに変更すると、FabやChipにも反映されます。

このように、Shapeを決まったサイズでグループ化する方法が?shapeAppearanceSmallComponentsです。
どれがどのコンポーネントに影響を与えるのかは、マテリアルデザインのドキュメントにあります。

shapeAppearanceとshapeAppearanceOverlay

Shapeに対応しているコンポーネントのshapeAppearanceは、デフォルトで?shapeAppearanceSmallComponents?shapeAppearanceMediumComponents?shapeAppearanceLargeComponentsのいずれかを参照しています。
先ほども書いたように、ButtonFloatingActionButtonであれば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ではshapeAppearanceOverlaycornerSize50%に設定しています。
この定義から、FabはshapeAppearanceではSmallComponentグループとしてカタチを踏襲し、shapeAppearanceOverlayShapeの大きさを変更しているということがわかります。

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を作っておくほうがよい。

styles.xml
<style name="YourApp.Widget.Button" parent="Widget.MaterialComponents.Button">
  <!-- No override attributes -->
</style>
themes.xml
<style name="YourApp.Theme" parent="Theme.MaterialComponents.Light.NoActionBar">
  <item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
layout.xml
<Button
  style="@style/YourApp.Widget.Button"
  ... />
AndroidManifest.xml
<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のスタイルをいじれないので、エンジニアが頑張りましょう!

88
68
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
88
68