[Flutter]タブの選択状態に応じてTabの表示を変える

Flutterの`Tab`の中に`Icon`や`Text`を使うと、タブの選択・非選択状態に応じて色がよしなに変わってくれます。しかし、カスタムWidgetや`Image.asset`を`Tab`の中に入れた場合は何も変化しません。本記事ではその対応方法についてお話します。

|標準的な実装|Image.assetを使用|理想型|
|:-:|:-:|:-:|
|![01-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/31fe9374-5745-03f0-eea6-6857be13c700.gif)|![02-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/eb7af2cf-a5cc-0f42-1962-3f8c0fbf1db9.gif)|![03-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/9b37e2e2-ac91-0973-3a8b-9bfeb4d6e048.gif)|

サンプルコードはここに置いてあります。
https://github.com/shcahill/FlutterTabSample

# 標準的な`Tab`の実装

一番素直にTabを実装すると、以下のようになると思います。

```dart:Tabの標準的な実装
TabController _controller;

＠override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      bottom: TabBar(
        controller: _controller,
        tabs: _buildTabs(context),
      ),
    ),
    body: TabBarView(
      controller: _controller,
      children: _buildTabPages(),
    ),
  );
}

/// [Tab]の配列を生成
List<Widget> _buildTabs() {
  return [
    Tab(icon: Icon(Icons.home), text: 'HOME'),
    Tab(icon: Icon(Icons.map),  text: 'MAP'),
  ];
}

/// タブの中身として表示するPage(Widget)の配列を生成
List<Widget> _buildTabPages() {
  return [
    HomePage(),
    MapPage(),
  ];
}
```

これを実行すると以下のようになります。

![標準的な実装](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/0009d97b-f6ad-82c2-0b62-31bf895f4b99.gif)


ここでは、選択中の`Tab`において文字・アイコンが共に白となり、非選択中のアイコンは逆にグレーとなっています。
そしてタブをスクロールさせると、タブインジケータの位置に応じてゆっくりと色が変化していくことがわかります。

# `Tab`の中身を変えてみる

次は`Icons`を使わずにassetに取り込んだpngを`Image.asset`で読み込んで表示させます。

```dart:Tabの中にImage.assetを使用する
List<Widget> _buildTabs() {
  return [
    Tab(icon: Image.asset('images/soccer.png')),
    Tab(icon: Image.asset('images/rugby.png')),
  ];
}
```

すると、表示はされるのですが、非選択状態のタブでもアイコンが黒く表示され、グレーにはなりません。

![Image.assetを使用した場合](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/f6acff7c-0285-3f4c-4a40-0fd0e93bcf95.gif)


## `AnimatedBuilder`を使ってタブインジケータの位置に応じて`Tab`の表示を変化させる

先ほどの`Tab`を[AnimatedBuilder](https://www.youtube.com/watch?v=N-RiyZlv8v8)で囲います。

```dart:AnimatedBuilderを使ってTabの表示を変化させる
//import 'dart:math' as math;

List<Widget> _buildTabs() {
  return [
    Tab(child: _buildAnimatedWidget(Image.asset('images/soccer.png'), 0)),
    Tab(child: _buildAnimatedWidget(Image.asset('images/rugby.png'), 1)),
  ];
}

/// [AnimatedBuilder]でラップすることにより、タブインジケータの位置に応じて[child]のopacityを変化させます。
/// [index]は生成する[Tab]のインデックスです
Widget _buildAnimatedWidget(Widget child, int index) {
  return AnimatedBuilder(
    animation: _controller.animation,
    builder: (_, __) {
      return Opacity(
        child: child,
        opacity: _calculateOpacity(index, _controller.animation.value),
      );
    },
  );
}

/// 計算対象の[Tab]の[index]と、タブインジケータのスクロール位置を示す[value]から、[Tab]内のWidgetのOpacityを算出します。
double _calculateOpacity(int index, double value) {
  if (index - 1 <= value && value <= index + 1) {
    // 選択状態および隣の[Tab]にフォーカスが当たっている状態
    final abs = (index - value).abs();
    // quiver/iterablesと衝突するため、`math`を付与
    return math.max(math.min(1.0 - abs, 1.0), 0.5);
  } else {
    // 非選択状態
    return 0.5;
  }
}
```

すると、`TabController`経由でタブインジケータの位置が変わるたびにbuilderが呼ばれ、Opacityの値が変更されるようになります。
以下で`AnimatedBuilder`で指定する内容についてみていきます。

### AnimatedBuilder#animation

ここには、`TabController#animation`を指定します。この値はdouble型(厳密にはAnimation＜double＞型)で、タブインジケータの位置を示します。例えば、一番左のタブを選択した状態なら0.0、2つ目のタブを選択中なら1.0を示します。つまり仮に3つ目のタブがあれば、この値は0.0~2.0の範囲の値をとることになります。

### AnimatedBuilder#builder

ここでは簡単な例として`Opacity`を使ってWidgetの透過度を変更しています。現在のタブの位置は`TabController#animation#value`を参照します。その値から透過度を計算しているのが`_calculateOpacity`の部分ですが、ここはただの算数ですので説明は割愛します。（このサンプルでは、選択状態ならOpacity=1.0、非選択状態ならOpacity=0.5の値をとるように計算しています）

![AnimatedBuilderを使用した場合](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/1f4997c5-78ce-a066-0be8-cecc0ccb2bba.gif)


このように`AnimatedBuilder`の実装次第で好きなようにタブを実装できます。