FlutterのThemeに任せてMaterial Designを適用する

# Material Designとは？

Material Designとは、Googleが提唱している、デザインの方針を定める一連のガイドラインみたいなものです。

これに従ってアプリをデザインすると、美しくて使いやすいアプリをデザインすることができます。

[Material Design](https://material.io)


FlutterのデザインやレイアウトのシステムはMaterial Designに準拠していますので、自分のアプリを作るときも極力それに逆らわず、流れに乗り、製作者の意図を汲んで、Material Designに準拠したアプリを作っていきたいものです。もっと細かくこだわりたい場合は別ですけれどもね。


# Material DesignにおけるTheme

[Implementing your theme](https://material.io/design/material-theming/implementing-your-theme.html)

ざっくりリンク先のページを見ていただくと、次の4つを決めてアプリ全体に適用すると、統一感のある美しいアプリにできるという旨が書いてあります。

 - Color
 - Typography
 - Shape
 - Icons

一つずつ簡単に説明します。

## Color

配色のことです。次の12個の色を決め、アプリ全体で使用すると良いです。

12個全部が別の色になっていなくてもいいです。

Material Designデフォルトの配色例がこれです。

![Material Designデフォルトの配色](https://lh3.googleusercontent.com/xdUHppClj12TZJ15G8o9sHruuSIc3sBNK0eTTbTU2cWb_FzpXj7MzVsPZ2-IKE1fY21WcXp0XEuS0TM6Qsns8TkCNePsDkHcC9xKHA=w1064-v0)

[Implementing your themeのColorの所](https://material.io/design/material-theming/implementing-your-theme.html#color)より引用しました。

## Typography

文字組みのことです。次の13個のスタイル(フォント・大きさ・太さ・色など)を決め、アプリ全体で使用すると良いです。

Material Designデフォルトのタイポグラフィ設定例がこれです。


![Material Designデフォルトのタイポグラフィ設定例](https://lh3.googleusercontent.com/0QHlCjJrHrpoSo07Td54bWXfeeGiBzbl7Gatak00n-kAEG5Y2RoOUs59y5GqLayUQmNlyBgczaKYvLJktFNNmok2FpmeRlRQpwrs=w1064-v0)

[Implementing your themeのTypographyの所](https://material.io/design/material-theming/implementing-your-theme.html#typography)より引用しました。

## Shape

ボタンをはじめとする様々な要素・コンポーネントの縁の形です。

「直角にする」「丸くする」「左上だけ斜めに切り落とす」などが考えられます。

一つ方針を決めて、アプリ全体で使用すると良いです。

## Icons

同じ目的のアイコンでも、「丸っこいもの」「角張ったもの」「中が塗られたもの」「塗られてないもの」など、いくつか種類があります。

これも一つ方針を決めて、アプリ全体で使用すると良いです。


# FlutterにおけるTheme


FlutterにはThemeという仕組みがあり、これを指定するとアプリ全体に自動的に適用され、統一感のあるアプリを作ることができます。

`ThemeData`という、一通りの設定を持ったクラスのインスタンスを、`MaterialApp`か`Theme`というWidgetに渡してWidgetツリーに組み込めば、その傘下のWidgetすべてに適用してくれます。

しかし、この`ThemeData`には大量の設定項目(プロパティ)があり、一つずつ設定するのはとても大変です。

こちらのリンク先に飛んだ瞬間、引数の多さに驚きます。

[ThemeData constructor](https://api.flutter.dev/flutter/material/ThemeData/ThemeData.html)

また、各プロパティにバラバラの色を設定することができてしまうので、統一感がなくなるリスクもあります。


# FlutterのThemeにMaterial Designを適用する



## `ThemeData.from`コンストラクタ

`ThemeData`クラスには、`ThemeData.from`というコンストラクタがあります。

このコンストラクタには、`colorScheme`と`textTheme`という引数を与えることができ、これがまさにMaterial DesignにおけるThemeのColorとTypographyに対応しているのです！


[ThemeData.from constructor](https://api.flutter.dev/flutter/material/ThemeData/ThemeData.from.html)

リンク先のドキュメントにも

> This is the recommended method to theme your application.

と、オススメであるとハッキリ書いてあります！

ということで、これを使いましょう。

`MaterialApp`の`theme`引数に与えて

```dart
MaterialApp(
  theme: ThemeData.from(
    colorScheme: 何かしらのColorScheme,
    textTheme: 何かしらのTextTheme,
  ),
  home: ScaffoldとかのWidget
)
```

という感じの構造にします。


## Color

[ColorScheme class](https://api.flutter.dev/flutter/material/ColorScheme-class.html)

`ColorScheme`クラスのコンストラクタは、まさにMaterial Designで規定されている12個の色を引数に取ります。

12個全て指定してもいいですが、下記のようにすると、基本はデフォルトの色を使いつつ、一部だけ異なる色を指定することができます。

この例では、`primary`のみ`Colors.red`に変え、あとはデフォルトの色を使うことになります。

```dart
ThemeData.from(colorScheme:ColorScheme.light(primary:Colors.red))
```

また、Widgetツリーの子孫から呼び出して読み取る場合も`ThemeData`から直接読み取るのではなく、一度`ColorScheme`を経由するといいでしょう。

```dart
color: Theme.of(context).colorScheme.primary,
```


## Typography

[TextTheme class](https://api.flutter.dev/flutter/material/TextTheme-class.html)

`TextTheme`クラスは、まさにMaterial Designで規定されている13種類のスタイルを引数に取ります。

以前はそうなっていませんでしたが、最近変更されてMaterial Design準拠になりました(執筆日:2021年2月11日)。古い引数はdeprecatedになっています。その経緯により、一部、Material Designで規定されているものと名前が違います。

最新のMaterial Designに準拠したデフォルトの`TextTheme`は

```dart
Typography.material2018().black
```

```dart
Typography.material2018().white
```

の2つです。明るい色を基調としたアプリの場合は文字色はblackにするため前者を、ダークテーマの場合は後者を選ぶといいでしょう。


これらを元に、色を変える場合はcopyWithを使ってこんな感じにしましょう。

```dart
Typography.material2018().black.copyWith(
          headline1: TextStyle(color: Colors.brown),
          subtitle1: TextStyle(color: Colors.orange), 
          bodyText1: TextStyle(color: Colors.pink),
          ...
        )
```

GoogleFontsを併用する場合はこんな感じ。

```dart
GoogleFonts.abrilFatfaceTextTheme(Typography.material2018().black.copyWith(
          headline1: TextStyle(color: Colors.brown),
          ...
        ))
```

なお、この`TextTheme`を使う場合、フォントサイズや太さは変えるべきではないです。

根拠はこちらのページ[black property](https://api.flutter.dev/flutter/material/Typography/black.html)に

>This TextTheme should provide color but not geometry (font size, weight, etc). A text theme's geometry depends on the locale.

とある通り、各プロパティのTextStyleのフォントサイズや太さは、言語設定(Locale)に依存してFlutterが自動で設定するからです。


### ScriptCategoryについて

[Internationalizing Flutter apps](https://flutter.dev/docs/development/accessibility-and-localization/internationalization)に従って正しくローカリゼーション設定をした場合、Flutterが言語設定に従ってTextThemeの各TextStyleのフォントサイズや太さやを指定してくれます。

その設定は[ScriptCategory enum](https://api.flutter.dev/flutter/material/ScriptCategory-class.html)として次の3種類が定義されています。

 - englishLike
    - 英語など
 - dense
    - 日本語など
 - tall
    - タイ語など


開発者である我々はこれを深く意識することなく、先程の

```dart
Typography.material2018().black
Typography.material2018().white
```

を使っておけば、Flutterが勝手に設定してくれます。

指定したい場合は

```dart
Typography.material2018().englishLike
```

などとすればよいです。




## ShapeとIconsについて

ShapeとIconsについては、全体的なテーマを一括で指定する方法はなさそうです。

### Shape

`ThemeData`の引数`CardTheme`や`ElevatedButtonTheme`などをそれぞれ指定するしかないと思います。`ThemeData.from`コンストラクタにはこれらの引数はありませんので、`.copyWith`を使って

```dart
ThemeData.from(
  colorScheme: yourColorScheme,
  textTheme: yourTextTheme,
).copyWith(
  elevatedButtonTheme: yourElevatedButtonThemeData,
  outlinedButtonTheme: yourOutlinedButtonTheme,
  cardTheme: yourCardTheme,
  dialogTheme: yourDialogTheme,
  ...
)
```
という感じです。面倒ですね。


Buttonにスタイルを適用する方法についてはこの記事がわかりやすいと思います。

[FlutterのButton系Widgetが置き換えられて変わったところ](https://qiita.com/poe_hoshi/items/06a104ba630d1cf6a5fa)


### Icons

`Icon`Widgetを使う度に、自分で決めた統一的な基準に従ってアイコンを選択しましょう。

[IconThemeData class](https://api.flutter.dev/flutter/widgets/IconThemeData-class.html)というものもありますが、これはcolor, opacity, sizeを指定するものなので、Material Designで言われているアイコンの形状に関するものとは別物です。

# 何がどこに適用されるか

`ColorScheme`や`TextTheme`を使って色やフォントを指定した場合、デフォルトではどこに適用されるのでしょう？

検証用アプリを作って検証してみましたのでその結果をご紹介します。なお、全てを列挙できているわけではないのでご了承ください。

下記のリストにないもの(ColorSchemeの`primaryVariant`やTextThemeの`headline1`など)は恐らくデフォルトで使われていないと思われますが、自分でそのプロパティをWidgetツリーの下部から指定すればもちろん使用可能です。

## ColorScheme

 - primary
    - appBarの背景
    - ElevatedButtonの色
    - OutlinedButton, TextButtonのテキストの色
    - BottomNavigationBarの選択されているもの
    - ダイアログのテキストボタン
 - secondary
    - FloatingActionButtonの色
    - スクロールが端までいった事を示すエフェクト(Androidのみ)
 - surface
    - Cardの色
    - ライセンスページのロード完了後の背景色
 - background
    - Scaffoldのbodyの背景色
    - ダイアログの背景色
    - Drawerの背景色
    - BottomNavigationBarの背景色
    - ライセンスページのロード中の背景色
 - error
    - TextFormFieldのvalidation失敗時の色
 - onPrimary
    - ElevatedButtonのテキストの色
    - AppBar内の選択中のタブを示す横棒
 - onSecondary
    - FloatingActionButtonのchildの色

## TextTheme

 - headline5
    - AboutDialogやライセンスページのapplicationName
 - headline6
    - AlertDialogのtitle
 - subtitle1
    - ListTileのtitle
    - AlertDialogのcontent
    - AboutDialogのchildren
    - ライセンスページのパッケージ名
 - bodyText1
    - DrawerHeaderのchild
    - Drawer内のListTileのtitle
 - bodyText2
    - 通常のText
    - Cardのchild
    - AboutDialogやライセンスページのバージョン名
 - caption
    - ListTileのsubtitle
    - BottomNavigationBarの選択されていないもの
    - AboutDialogのapplicationLegalese

BottomNavigationBarの中身が、選択時は`ColorScheme`の`primaryColor`で、非選択時は`TextTheme`の`caption`の色になるのは面白いと思いました。

通常の本文テキストが`bodyText1`ではなく`bodyText2`なのも面白い、というか罠ですね。

## 何も適用されない主な場所

 - AppBar内の諸項目
    - title
    - leading
    - actions
    - bottomに配置するTabBar内のIconやText

AppBarの中では、背景色がprimary、選択中のタブを示す横棒がonPrimaryになる以外、どの色も適用されないようです。
AppBarにTextThemeを適用する方法については後述します。


 - ListTile両端のアイコン
 - 非選択時のTextFormFieldの下線やラベルの色
 - disabled状態のボタン

これらは灰色のままです。

# 検証用アプリの紹介

今回の検証にはこちらのアプリを使用しました。

GitHubからcloneして起動すれば使えるはずです。

https://github.com/agajo/learn_flutter_material_design

このアプリの各所に指定されている色と、ソースコードで指定している色を見比べてみてください。

BottomNavigationBarからcolorSchemeというボタンを押すと、colorSchemeだけをいろいろ指定したモードになります。


![Simulator Screen Shot - iPhone 8 - 2021-02-14 at 21.22.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/e5d33478-009d-17b5-421b-b20fa8654e26.png)



BottomNavigationBarからtextThemeというボタンを押すと、textThemeだけをいろいろ指定したモードになります。この時のcolorSchemeには白・黒・グレーだけを使用したColorSchemeを指定しているので、色がついているところはtextTheme由来だということが明確になります。

![Simulator Screen Shot - iPhone 8 - 2021-02-14 at 21.22.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/73c865d2-89cc-85f9-471e-3760c07c64bd.png)



なお、アイコンの選択は完全にテキトーです。

また、アプリの目的上できるだけバラバラに色を指定していますので、デザインとして美しいかどうかはお察しください。特にtextThemeだけ指定してある方、ひどいですね。

それから、Localeを変化させることでScriptCategoryを3通り(`englishLike`, `dense`, `tall`)変化させることができるようにしてありますが、その際表示する文字列は変化しません(英語から日本語やタイ語になったりはしません)。同じ文字列で比較することでScriptCategoryによる違いを見るためです。

ただし、Flutterデフォルトの文字列が使われている所(AboutDialogのライセンス表示ボタンなど)は言語が変化します。

Drawerを開いて、AboutDialogやライセンスページも見てみてくださいね。

# じゃあAppBarにTextThemeを適用する方法は？

最後に、AppBarにTextThemeを適用する方法をご紹介します。

## 良くない方法

「そんなん、MaterialAppの`theme`引数に渡すThemeDataの`appBarTheme`に適当な`AppThemeData`を渡すだけじゃん」と思うかもしれません。

その通りなのですが、そこに罠があります。

AppBarのtitleテキストのフォントを、GoogleFontを使って変えたいとします。

先程のように`ThemeData.from`コンストラクタを使ってThemeDataを作った後、`.copyWith`で`appBarTheme`を上書きしてみます。

```dart
final myThemeData = ThemeData.from(
  colorScheme: ColorScheme.light(), // 好きなColorSchemeでOK
  textTheme: Typography.material2018().black, // 好きなTextThemeでOK
).copyWith(
    appBarTheme: AppBarTheme(textTheme: GoogleFonts.pacificoTextTheme()));
```

結果はこんな感じ。

![スクリーンショット 2021-02-15 23.55.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/b564779e-45fc-68fd-e141-e0c743edbe35.png)


確かにフォントは変わっていますが、なんだか小さいです。

色も黒くて見にくいし、左のハンバーガーメニューと色が違っていて変です。

これは、もともと使われていたAppBar用のTextThemeを完全無視して、新たに生成したTextThemeを渡してしまったせいです。

## 解説

そもそもどうして`ThemeData.from`コンストラクタに渡したTextThemeは、AppBarに適用されないのでしょうか？

実は、`ThemeData.from`コンストラクタに渡したTextThemeはそのままThemeDataの`textTheme`プロパティに渡されるのですが、

一方で、AppBarはThemeDataの`primaryTextTheme`プロパティを読みに行くからです。

ではじゃあ`primaryTextTheme`にも`textTheme`プロパティと同じTextThemeを渡せばいいかと言うと、そういうわけでもない。

`primaryTextTheme`は、primaryに設定した色の上に表示した時に見えやすい色を設定する必要があります。単に`textTheme`プロパティと同じにしてしまうと、たいへん見づらい結果を招くことがあるのです。


ちなみに、`iconTheme`、`primaryIconTheme`というプロパティもあり、同様の構造になっています。



## 良い方法

ということで、もともと`primaryTextTheme`に設定されているTextThemeを可能な限り活かしながら、最低限のフォント(と、ついでに色）を変更することにします。

`primaryIconTheme`も同様にします。

このようにすると良いでしょう。

まず`ThemeData.from`を使ってThemeDataをつくります。

```dart
final _tempThemeData = ThemeData.from(
  colorScheme: ColorScheme.light(), // 好きなColorSchemeでOK
  textTheme: Typography.material2018().black, // 好きなTextThemeでOK
);
```

これを元に、`primaryTextTheme`を、**もともと設定されていたものを最大限残しながら**部分的に変更します。(iconThemeも同様)

```dart
final themeData = _tempThemeData.copyWith(
  primaryTextTheme:
      GoogleFonts.pacificoTextTheme(_tempThemeData.primaryTextTheme).apply(
    bodyColor: Colors.yellow,
  ),
  primaryIconTheme:
      _tempThemeData.primaryIconTheme.copyWith(color: Colors.yellow),
);
```

GoogleFontsではなく手元のフォントファイルを指定する場合はこう。

```dart
final themeData2 = _tempThemeData.copyWith(
  primaryTextTheme: _tempThemeData.primaryTextTheme.apply(
    fontFamily: 'DancingScript',
    bodyColor: Colors.yellow,
  ),
  primaryIconTheme:
      _tempThemeData.primaryIconTheme.copyWith(color: Colors.yellow),
);
```

これでうまくいきます。


![スクリーンショット 2021-02-16 0.15.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/aa1a49fc-8d7d-107c-16bb-13c8a1a25f61.png)


大きさも適切で、色設定もフォント設定もうまくいきましたね。

「ハンバーガーメニューは元の色でいいわ！」という場合は`primaryIconTheme`プロパティの設定をしなければいいです。

ちなみにですが、AppBarのtitleには`primaryTextTheme`に渡したTextThemeの`headline6`プロパティにあるTextStyleが適用されます。へ〜。

### 補足

ThemeDataの`appBarTheme`プロパティにAppBarThemeを渡しても良かったのですが、`primaryTextTheme`プロパティを設定しておくほうがより全体に包括的に設定できるので良いと思います。

具体的には、CircleAvatarやFlexibleSpaceBar、SearchPageなどを使った場合に違いが出そうです(ソースコードより推察。未検証)。


# おわり

おわりです。

用意されている機能を極力そのまま利用して、統一感のあるアプリを作っていきましょう！