FlutterでちょっとイケてるTabを書く


### Flutterのデフォルトのタブ、ださくないですか？

という気持ちに端を発して以下の画像のようなタブを作る方法を解説してみます。

![スクリーンショット 2020-12-11 0.22.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/190157/a34758d2-e85b-7830-869b-ea96f1622e3e.png)


## 実装
なんとなくSliverで書いた方が難しいかなと思ったのでそちらで書いています。
なお、基本的なTabBarの書き方については過去から特に変わっていないため、解説をしません。

- デフォルトのものを作る場合→ https://flutter.ctrnost.com/basic/navigation/tabbar/
- カスタマイズをする場合→ https://qiita.com/Dreamwalker/items/cc19bb4f8b7068ae0fd3

あたりを参考にしてください。


### Size Config

https://medium.com/flutter-community/flutter-effectively-scale-ui-according-to-different-screen-sizes-2cb7c115ea0a
こちらの記事を参考にしています。
要約すると、デバイス間での大きさに誤差を生まないために、サイズは定数で指定するのではなく幅に合わせて指定しようねという話です。

今回は以下のような関数を便利関数として用意しました。

```dart
double getProportionHeight(double inputHeight) {
  final screenHeight = SizeConfig.screenHeight;
  return (inputHeight / 812.0) * screenHeight;
}

double getProportionWidth(double inputWidth) {
  final screenWidth = SizeConfig.screenWidth;
  return (inputWidth / 375.0) * screenWidth;
}
```

812x375というのはiPhoneXのサイズです。
デザイナーの方と仕事をする場合、iPhoneXでデザインをしたものをそのまま他のデバイスでも違和感なくみせるためのコードです。
本論とは直接は関係ないのですが、知っておくと便利かと思いますのでご紹介させていただきました。
(参考: https://qiita.com/pe-ta/items/b3b754bde584abc98f44)

### タブの実装

とはいっても特に難しいことをするわけではないです。

#### タイトル部分
以下のような感じで、Expandedを使って左から6割までに配置するようにしました。

```dart
  // タブの左寄せタイトル郡
  Widget _buildTabTitles() {
    return Row(
      children: [
        Expanded(
          flex: 3,
          child: TabBar(
            controller: _tabController,
            indicator: BoxDecoration(
              border: Border(
                  bottom: BorderSide(
                width: getProportionWidth(3),
                color: Colors.white,
              )),
            ),
            labelColor: Colors.white,
            unselectedLabelColor: Colors.grey,
            tabs: [
              _tabTitle('Car'),
              _tabTitle('Bike'),
              _tabTitle('Walk'),
            ],
          ),
        ),
        Expanded(flex: 2, child: Container()),
      ],
    );
  }

  Widget _tabTitle(String title) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: getProportionWidth(6)),
      child: Container(
        height: getProportionHeight(40),
        width: getProportionWidth(132),
        child: Tab(text: title),
      ),
    );
  }
```

#### アイコン部分
こちらは逆に、Paddingをいれつつ右詰めにしています。

```dart

  Widget _buildTabActions() {
    return Container(
      // tab height
      height: getProportionWidth(40),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          _tabAction(
              icon: const Icon(Icons.sort_outlined, color: Colors.white),
              onTap: () {}),
          _tabAction(
              icon: const Icon(Icons.filter_alt_outlined, color: Colors.white),
              onTap: () {}),
        ],
      ),
    );
  }

  Widget _tabAction({@required Icon icon, @required Function onTap}) {
    return Padding(
      padding: EdgeInsets.only(right: getProportionWidth(20)),
      child: GestureDetector(
        onTap: () => onTap,
        child: icon,
      ),
    );
  }
```

最後にこれらをStackで重ねてあげればWidget自体は完成です。
文字部分は6割まで、アイコン部分は右詰めをするようにしているため、横幅が広い場合でも真ん中にスペースがしっかりとできるようになります。

```dart
  Widget _buildTabsRow() {
    return Stack(
      children: [
        _buildTabTitles(),
        _buildTabActions() // 並び替えとフィルターのactions
      ],
    );
  }
```


### PreferredSize Widget

実は上述で作ったWidgetをそのままTabとして使うことはできません。
Appbarのbottomには`PreferredSize` widgetしかいれることができないからです。

なので、最後に `PreferredSize` widgetでどれくらいからbottomを開始するのかを指定すれば完成です。

```dart
SliverAppBar(
    title: Text('Library'),
    bottom: PreferredSize(
        preferredSize: Size.fromHeight(getProportionHeight(48)),
        child: _buildTabsRow())),
```

参考: https://daichan.club/flutter/78992

## コード全文


```dart
class LibraryScreen extends StatefulWidget {
  const LibraryScreen({Key key}) : super(key: key);

  @override
  _LibraryScreenState createState() => _LibraryScreenState();
}

class _LibraryScreenState extends State<LibraryScreen>
    with SingleTickerProviderStateMixin {
  TabController _tabController;

  @override
  void initState() {
    _tabController = TabController(length: 3, vsync: this);
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
    _tabController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: NestedScrollView(
            headerSliverBuilder: (context, value) {
              return [
                SliverAppBar(
                    title: Text('Library'),
                    bottom: PreferredSize(
                        preferredSize: Size.fromHeight(getProportionHeight(48)),
                        child: _buildTabsRow())),
              ];
            },
            body: _tabBody()));
  }

  Widget _tabBody() {
    return TabBarView(
      controller: _tabController,
      children: [
        Container(child: Center(child: Icon(Icons.car_rental))),
        Container(child: Center(child: Icon(Icons.car_rental))),
        Container(child: Center(child: Icon(Icons.car_rental))),
      ],
    );
  }

  Widget _buildTabsRow() {
    return Stack(
      children: [
        _buildTabTitles(),
        _buildTabActions() // 並び替えとフィルターのactions
      ],
    );
  }

  // タブの左寄せタイトル郡
  Widget _buildTabTitles() {
    return Row(
      children: [
        Expanded(
          flex: 3,
          child: TabBar(
            controller: _tabController,
            indicator: BoxDecoration(
              border: Border(
                  bottom: BorderSide(
                width: getProportionWidth(3),
                color: Colors.black,
              )),
            ),
            labelColor: Colors.white,
            unselectedLabelColor: Colors.grey,
            tabs: [
              _tabTitle('Car'),
              _tabTitle('Bike'),
              _tabTitle('Walk'),
            ],
          ),
        ),
        Expanded(flex: 2, child: Container()),
      ],
    );
  }

  Widget _buildTabActions() {
    return Container(
      // tab height
      height: getProportionWidth(40),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          _tabAction(
              icon: const Icon(Icons.sort_outlined, color: Colors.white),
              onTap: () {}),
          _tabAction(
              icon: const Icon(Icons.filter_alt_outlined, color: Colors.white),
              onTap: () {}),
        ],
      ),
    );
  }

  Widget _tabAction({@required Icon icon, @required Function onTap}) {
    return Padding(
      padding: EdgeInsets.only(right: getProportionWidth(20)),
      child: GestureDetector(
        onTap: () => onTap,
        child: icon,
      ),
    );
  }

  Widget _tabTitle(String title) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: getProportionWidth(6)),
      child: Container(
        height: getProportionHeight(40),
        width: getProportionWidth(132),
        child: Tab(text: title),
      ),
    );
  }
}
```