[Flutter]TabBarの背景色をAppBarと異なる色に変更する

# TL;DR,

- `TabBar`を`Ink`で囲む
- `Ink`の中で`color`を指定する

(2020/3/22修正：MaterialではなくInkを使うように修正)

```dart:TabBarの背景色を変えるカスタムウィジェット
/// [TabBar]をwrapして背景色を変更するWidget
class ColoredTabBar extends StatelessWidget implements PreferredSizeWidget {
  final PreferredSizeWidget tabBar;
  final Color color;

  // コンストラクタでchildとなる[TabBar]と背景色を指定
  ColoredTabBar({@required this.tabBar, @required this.color});

  @override
  Widget build(BuildContext context) {
    // [Ink]でwrapして背景色を指定
    return Ink(
      color: color,
      child: tabBar,
    );
  }

  // [AppBar]のbottomに指定するためには[PreferredSizeWidget]である必要があり、そのためにこのmethodをoverrideします。
  // 実態はchildである[TabBar]のpreferredSizeをそのまま使えばOK
  @override
  Size get preferredSize => tabBar.preferredSize;
}
```

# `TabBar`で背景色を簡単に変更できない理由

Flutterの`TabBar`の背景色を変える場合、一手間必要になります。
`AppBar`と同じ色であれば、`TabBar`の色も簡単に変更できるのですが、`AppBar`とは別の色にしたいとなると、少し面倒になります。
やりたいことの割りに少し説明が長くなりますが、順を追って説明します。

## `TabBar`の標準的な使い方

まずは`TabBar`の一般的な使い方はこちらです。

```dart:TabBarの一般的な使い方
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      bottom: TabBar(
        controller: _tabController,
        tabs: _buildTabs(),
      ),
    ),
    body: TabBarView(
      controller: _tabController,
      children: _buildTabPages(),
    ),
  );
}
```

<img width="400" alt="標準的なTabBarの使い方" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/8a98208e-eafa-ab6c-9816-d22d7035c40d.png">

## `AppBar`と`TabBar`の背景色は、何が使われているのか

`AppBar`の色は、`AppBarTheme#color`または`Theme#primaryColor`が使用されます。しかし、`AppBar`には`backgroundColor`というプロパティがあるため、そこで背景色を指定することが可能です。

```dart:AppBarの背景色指定
return Scaffold(
  appBar: AppBar(
    title: Text('Tab Sample'),
    backgroundColor: Colors.blue,
    ...
```

一方、`TabBar`には背景色のパラメータはありません。`TabBar`自体は透過で、その下にある`AppBar`の色が見えています。そのため、基本的には**【`TabBar`の色を変える＝`AppBar`の色を変える】**ということになります。

たしかに、[MaterialDesignのサイト](https://material.io/components/tabs/)ではAppBarの色とTabBarの色が統一されたサンプルしか見たことはないので、基本的にはそれに従うのが良いのかもしれません。（ドキュメントでは明記されてなかったと思いますが。）


ちなみに、`TabBar`を`AppBar`の中以外の場所に表示させると、親Widgetの背景色が表示されていることがわかります。

## `Container`などで背景色を指定する？

では、`TabBar`に背景色をつけるにはどうすれば良いでしょうか。
最初に思いつくのは、`Container`でラップして色をつけるやり方です。すると、ぱっと見はうまく表示されているように見えます。が、タップしても**Ripple Effectが表示されません**…

Ripple Effectは背景色の効果のため、今回のようにWidgetに背景色を付けるとRipple Effectが見えなくなってしまうためです。

### Ripple Effectについて

少しRipple Effectの説明をします。
Ripple Effectは、`InkWell`ウィジェットでタッチイベントが発生した際に描画されるものですが、このRipple Effectの描画自体は`InkWell`ウィジェットの祖先にある`Material`ウィジェット上で描画されます。
(厳密には`MaterialInkController`をimplementした`_RenderInkFeatures`というRenderObjectで描画されます)
そのため以下のことが言えます。

- 祖先に`Material`が存在しない場合は、描画する人がいないためにRipple Effectは表示されない
- `InkWell`と`Materil`の間のレイヤーに、色を持つWidgetが存在するとRipple Effectが遮られて見えなくなる

```dart:ink_well.dart
// [InkWell]は[InkResponse]を継承したもの。
class _InkResponseState<T extends InkResponse> extends State<T> with AutomaticKeepAliveClientMixin<T> {
  InteractiveInkFeature _createInkFeature(Offset globalPosition) {
    // [InkWell]では祖先の[Material]を探索する
    final MaterialInkController inkController = Material.of(context);
    ...
    // 祖先の[Material]を使ってRipple Effectを発生させる
    InteractiveInkFeature splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create(
      controller: inkController,
      ...
    );
    return splash;
  }
  ...
}
```

```dart:material.dart
class Material extends StatefulWidget {
 static MaterialInkController of(BuildContext context) {
    final _RenderInkFeatures result = context.findAncestorRenderObjectOfType<_RenderInkFeatures>();
    return result;
  }
  ...
```


参考記事）
[Flutter 背景色ありで Ripple Effect を効かせる](https://qiita.com/najeira/items/db6edb1c8d8f75042b98)

## なぜ`TabBar`でRipple Effectが表示されないのか

以上のことを踏まえた上で、`AppBar`と`TabBar`の場合について見ていきます。
まず、`AppBar`と`TabBar`の位置関係はこのようになっています。

<img width="400" alt="AppBarとTabBar" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/e9b4ea84-39b0-5341-2ad1-4c6a308b1e06.png">

`AppBar`と`TabBar`は縦方向に並んでいるのではなく、`AppBar`の上に`TabBar`が重なって乗っています。
ここに、Ripple Effectを表現するために重要となる`InkWell`と`Material`のレイヤーを表示すると以下のようになります。

<img width="400" alt="InkWellとMaterial" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/861afd1a-ad92-8420-7b1e-30ac795930a0.png">


`AppBar`の中に`Material`があり、`TabBar`の中に`InkWell`が存在しています。
コードはこちら。

```dart:app_bar.dart
class _AppBarState extends State<AppBar> {
  @override
  Widget build(BuildContext context) {
    ...
    return Semantics(
      container: true,
      child: AnnotatedRegion<SystemUiOverlayStyle>(
        value: overlayStyle,
        child: Material(       // <- ここに[Material]がある
          // 背景色は`backgroundColor` > [AppBarTheme] > [Theme]の順に適用されます
          color: widget.backgroundColor
            ?? appBarTheme.color
            ?? theme.primaryColor,
          elevation: widget.elevation
            ?? appBarTheme.elevation
            ?? _defaultElevation,
          shape: widget.shape,
          child: Semantics(
            explicitChildNodes: true,
            child: appBar,
          ),
        ),
      ),
    );
  }
  ...
}
```

```dart:tabs.dart
class _TabBarState extends State<TabBar> {
  @override
  Widget build(BuildContext context) {
  ...
    // Add the tap handler to each tab. If the tab bar is not scrollable,
    // then give all of the tabs equal flexibility so that they each occupy
    // the same share of the tab bar's overall width.
    final int tabCount = widget.tabs.length;
    for (int index = 0; index < tabCount; index += 1) {
      // ここで[TabBar]の中の[Tab]を[InkWell]でラップしています
      wrappedTabs[index] = InkWell(
        onTap: () { _handleTap(index); },
        child: Padding(
          padding: EdgeInsets.only(bottom: widget.indicatorWeight),
          child: Stack(
            children: <Widget>[
              wrappedTabs[index],
              Semantics(
                selected: index == _currentIndex,
                label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount),
              ),
            ],
          ),
        ),
      );
      ...
    }
    ....
  }
}
```

`TabBar`では`Tab`の配列を持っているため、その各`Tab`に対して`InkWell`でラップしています。そして、その各`Tab`の`InkWell`のRipple Effectを描画するのは祖先である`AppBar`の上の`Material`となります。

先の`Container`を使った例では、この`AppBar/Material`と`TabBar/InkWell`の間に、色付きの`Container`を挟んでしまったことにより、`AppBar/Material`上で描画されているはずのRipple Effectが、色付き`Container`に遮られて見えなくなっていました。

# Ripple Effectにも対応しつつ、`TabBar`の背景色を変更する

ここまで分かれば解決策は簡単で、`TabBar`を`Ink`で囲ってあげれば問題なく動くようになります。`Ink`は`Material`階層化にあるWidgetにRipple Effectを効かせるためのウィジェットで、`color`プロパティがあるため、そこで色を指定できます。
また、`AppBar`の`bottom`に指定するためには`PreferredSizeWidget`をimplementしている必要があるため、カスタムWidgetを作ります。

```dart:TabBarの背景色を変えるカスタムウィジェット
class ColoredTabBar extends StatelessWidget implements PreferredSizeWidget {
  final PreferredSizeWidget tabBar;
  final Color color;

  ColoredTabBar({@required this.tabBar, @required this.color});

  @override
  Widget build(BuildContext context) {
    return Ink(
      color: color,
      child: tabBar,
    );
  }

  @override
  Size get preferredSize => tabBar.preferredSize;
}
```

使うときは`TabBar`を`ColoredTabBar`で囲ってあげるだけです。

```dart:ColoredTabBarの使い方
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      bottom: ColoredTabBar(
        color: Colors.red,
        tabBar: TabBar(
          controller: _controller,
          tabs: _buildTabs(),
        ),
      ),
    ),
    body: TabBarView(
      controller: _tabController,
      children: _buildTabPages(),
    ),
  );
}
```

ちなみに`Ink`ウィジェットは`InkWell`と同様の仕組みを使って、`Material`上に直接描画しています。（ここは別途まとめたい。）

# まとめ

`TabBar`の背景色を変えるためには

- `TabBar`を`Ink`で囲む
- `Ink`の中で`color`を指定する

---
補足）

投稿当初、`Ink`ではなく`Material`を使った実装を紹介していましたが、@mono0926 さんのご指摘により`Ink`を使った実装に修正しました:pray:  `Material`でも動作はしますが、`Ink`の方がベターです。
[InkResponseのドキュメント](https://api.flutter.dev/flutter/material/InkResponse-class.html)のTroubleshootingの部分にも記載がありました。

> The Ink widget can be used as a replacement for Image, Container, or DecoratedBox to ensure that the image or decoration also paints in the Material itself, below the ink.

また、Ripple Effectまわりの話もTroubleshootingの部分に記載があります。