AndroidのTheme/Styleを理解する

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`属性で、継承元のスタイルリソースを指定します。

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

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

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


### 暗黙的継承

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

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

<style name="YourButtonStyle.Outlined" />
```

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


### 命名と継承

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

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

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

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

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

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

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


# Theme

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

```xml:layout.xml
<View
  android:background="?colorPrimary"
  ... />
```

```xml: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/`または`?`で参照できます。

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

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

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

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


### コードで参照する場合

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

typedValue.data;
```

```kotlin: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の直接の値を扱います。

```xml:layout.xml
<View
  style="@style/ViewStyle"
  ... />
```

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


## View Style

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

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

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

```java:TextView.java
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**に依存します。

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

```java: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) {
    ...
  }
}
```

```xml:themes.xml
<style name="Theme.WithContext" parent="...">
  <item name="android:textViewStyle">@style/YourApp.Widget.TextView</item>
</style>
```

```xml: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

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

#### 2. style attrs

```xml:layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
```

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


### How does View resolve Theme? 

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


#### 1. ThemeOverlay

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

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

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

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


#### 2. MaterialThemeOverlay

```xml:themes.xml
<style name="YourApp.Theme" parent="...">
  <item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
```

```xml: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`で始まるようにしようとしていたような名残があります。(推察)

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

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


### 値の解決

#### 1. View text's attrs

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


#### 2. View `textAppearance` style-resource text's attrs

```xml:layout.xml
<TextView
  android:textAppearance="@style/YourApp.TextAppearance"
  ... />
```

```xml:styles.xml
<style name="YourApp.TextAppearance" parent="...">
  <!-- Reference text's Attributes here -->
</style>
```


#### 3. style text's attrs

```xml:layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
```

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


#### 4. style `textAppearance` style-resource text's attrs

```xml:layout.xml
<TextView
  style="@style/YourApp.Widget.TextView"
  ... />
```

```xml: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

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


#### 2. View `shapeAppearanceOverlay` style-resource shape's attrs

```xml:layout.xml
<Button
  app:shapeAppearanceOverlay="@style/YourApp.ShapeAppearanceOverlay.Button"
  .../>
```

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


#### 3. View `shapeAppearance` style-resource shape's attrs

```xml:layout.xml
<Button
  app:shapeAppearance="@style/YourApp.ShapeAppearance.Button"
  ... />
```

```xml: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`が参照しています。
これはレイアウトで書くと以下のような状態です。(実際はデフォルトスタイルで設定されています)

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

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

```xml:themes.xml
<style name="YourApp.Theme" parent="...">
  <item name="shapeAppearanceSmallComponents">@style/YourApp.ShapeAppearance.Small</item>
</style>
```

```xml:styles.xml
<style name="YourApp.ShapeAppearance.Small" parent="...">
  <item name="cornerFamily">cut</item>
</style>
```

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

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

https://material.io/design/shape/about-shape.html#shape-customization-tool



### shapeAppearanceとshapeAppearanceOverlay

Shapeに対応しているコンポーネントの`shapeAppearance`は、デフォルトで`?shapeAppearanceSmallComponents`、`?shapeAppearanceMediumComponents`、`?shapeAppearanceLargeComponents`のいずれかを参照しています。
先ほども書いたように、`Button`や`FloatingActionButton`であればSmallが参照されています。

一方でShapeを扱うViewには、`shapeAppearance`とは別に、`shapeAppearanceOverlay`属性が設定できます。
では、この2つはどのように使い分けるのでしょうか。

ButtonとFabはそれぞれ以下のように定義されています。

**Button**

```xml
<style name="Widget.MaterialComponents.Button" parent="Widget.AppCompat.Button">
  ...
  <item name="shapeAppearance">?attr/shapeAppearanceSmallComponent</item>
</style>
```

**Fab**

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

```xml:styles.xml
<style name="YourApp.Widget.Button" parent="Widget.MaterialComponents.Button">
  <!-- No override attributes -->
</style>
```

```xml:themes.xml
<style name="YourApp.Theme" parent="Theme.MaterialComponents.Light.NoActionBar">
  <item name="materialButtonStyle">@style/YourApp.Widget.Button</item>
</style>
```

```xml:layout.xml
<Button
  style="@style/YourApp.Widget.Button"
  ... />
```

```xml: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のスタイルをいじれないので、エンジニアが頑張りましょう！