FlutterでBottomNavigatorBar を残したまま各画面を遷移させる

BottomNavigatorとは下タブのボタンたちです。色々なアプリで使用されていて、アプリを作る際によく使うと思います。ごく普通に使えば問題なく動作するのですが、TwitterやInstagramのように下タブを隠さないように各タブ内で遷移させようと思うと一捻りする必要があります。

実際にサンプルアプリを作ってみようと思います。
特に指定は無いので`flutter create` で１つ新規でアプリを作ってみてください。



##シンプルなbottomNavigationBar付きのアプリを作る

まずは特に考えないでBottomNavigatonBarを使ったWidgetを作ってみます。
今回はホーム、タイムライン、設定の３タブ構成で作ります。普通に組み合わせて作ると下記のようなコードになると思います。
runApp(); や MaterialAppに関しては省略させていただきます。コード全体を一番下に公開しています。全体像を見たい方は下のリンクからお願いします

```Dart:main.dart
import 'package:flutter/material.dart';
import './pages.dart';

class MainPage extends StatefulWidget {
  MainPage({Key key}) : super(key: key);

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

class _MainPageState extends State<MainPage> {
  int _currentIndex = 0;
  final items = <BottomNavigationBarItem>[
    BottomNavigationBarItem(
      icon: Icon(Icons.home),
      title: Text('ホーム'),
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.timeline),
      title: Text('タイムライン'),
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.settings),
      title: Text('設定'),
    ),
  ];
  final tabs = <Widget>[
    Home(),
    Timeline(),
    Config(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('demo'),
      ),
      body: Stack(
        children: <Widget>[
          IndexedStack(
            index: _currentIndex,
            children: tabs,
          ),
        ],
      ),
      bottomNavigationBar: _buildBttomNavigator(context),
    );
  }

  Widget _buildBttomNavigator(BuildContext context) {
    return BottomNavigationBar(
      items: items,
      currentIndex: _currentIndex,
      onTap: (index) {
        if (_currentIndex != index) {
          setState(() {
            _currentIndex = index;
          });
        }
      },
    );
  }
}
```



BottomNavigatonBarで切り替わる要素は下記の通りにしておきました。Timeline()、Config()ではクラス名、ボタン内テキストが変わるだけでその他は同じです。

```Dart:home.dart

import 'package:flutter/material.dart';
import 'package:./pages.dart';

class Home extends StatelessWidget {
  const Home({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Center(
        child: FlatButton(
          child: Text('Home'),
          color: Colors.amberAccent,
          onPressed: () {
            Navigator.of(context).push<Widget>(
              MaterialPageRoute(
                builder: (context) {
                  return Detail();
                },
              ),
            );
          },
        ),
      ),
    );
  }
}

```

![6s0hed75.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91583/05ae6cd4-ce84-4f30-7797-a7205c819126.gif)

困ったことにボタンを押して画面が遷移すると下タブが見えなくなってしまいます。

TwitterやInstagramみたいな各タブ内で遷移していくようにしたいんだけどな.........

##問題点
FlutterのNavigatorはスタックで管理させています。
Pushのたびに上に上に画面が乗っていく感じです。Popをすると一番上に乗っているものを取り除いてひとつ下のページが表示されて戻ったことになっています。
つまり遷移するとBottomNavigatonBarがあるWidgetの上に新しいWidgetが乗ってしまいます。

![無題の図形描画.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91583/97e8497a-6c5d-df30-3a93-90d080fb698e.jpeg)


#解決策
Navigatorの中にNavigatorを入れて複数のNavigatorでナビゲーションを管理します。
下記の図に示しているように、tab内で表示されている要素の上に新しいページを乗せて遷移させていく方法で実装しようと思います。

![無題の図形描画 (2).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91583/4dd7a46c-5e95-f93a-43c2-e9263bd13b8a.jpeg)

ちなみにタブ内で遷移していますが、ページによってはBottomNavigatonBarを隠して、全画面表示にしたこともあると思いますが問題なく全画面表示にすることができます。
Flutterのsubtree的には下記のようになっています

```
Navigator
 - BottomNavigatorBar
   - Navigator
 　  - Widget
   - Navigator
   　- Widget
   - Navigator
   　- Widget
   - Navigator
   　- Widget
```

この方法しか知らないので○○というWidget使えばいい感じに動くし、簡単に出来るよみたいなことを知っている方がいたらコメント欄で教えて下さい

##Navigator用にタブの数の`GlobalKey<NavigatorState>>`を用意する
各タブ内にNavigatorで遷移するときに使用する`GlobalKey<NavigatorState>>`を用意します。
タブの数分必要になります。今回はタブの数が３つなので３つ用意します。

```Dart:main.dart
class _MainPageState extends State<MainPage> {
  TabItem _currentTab = TabItem.home;
  Map<TabItem, GlobalKey<NavigatorState>> _navigatorKeys = {
    TabItem.home: GlobalKey<NavigatorState>(),
    TabItem.timeline: GlobalKey<NavigatorState>(),
    TabItem.config: GlobalKey<NavigatorState>(),
  };
 // 省略
}
```

TabItemはenumで用意しています。MainPage　class の上なり別ファイルなりの適当なところに書いてください。

```dart
enum TabItem {
  home,
  timeline,
  config,
}
```

## TabNavigator
Tab内で遷移するのに必要なNavigatorを用意します。
Navigatorには各タブの`GlobalKey<NavigatorState>()`が渡り、引数のNavigatorKeyで遷移するようになります。

```dart:tab_navigator.dart
class TabNavigator extends StatelessWidget {
  const TabNavigator({
    Key key,
    @required this.tabItem,
    @required this.routerName,
    @required this.navigationKey,
  }) : super(key: key);

  final TabItem tabItem;
  final String routerName;
  final GlobalKey<NavigatorState> navigationKey;

  Map<String, Widget Function(BuildContext)> _routerBuilder(BuildContext context) => {
    '/home': (context) => const Home(),
    '/timeline': (context) => const Timeline(),
    '/config': (context) => const Config(),
  };

  @override
  Widget build(BuildContext context) {
    final routerBuilder = _routerBuilder(context);

    return Navigator(
      key: navigationKey,
      initialRoute: '/',
      onGenerateRoute: (settings) {
        return MaterialPageRoute<Widget>(
          builder: (context) {
            return routerBuilder[routerName](context);
          },
        );
      },
    );
  }
}
```

## BottomNavagtorBar
下タブのBottomNavigatorBarを用意します。
特に変わったことはしていませんが、選択状態の管理を`currentIndex`でするのではなく、TabItemでしています。

```dart:bottom_navigator.dart
const tabTitle = <TabItem, String>{
  TabItem.home: 'ホーム',
  TabItem.timeline: 'タイムライン',
  TabItem.config: '設定',
};
const tabIcon = <TabItem, IconData>{
  TabItem.home: Icons.home,
  TabItem.timeline: Icons.timeline,
  TabItem.config: Icons.settings,
};

class BottomNavigation extends StatelessWidget {
  const BottomNavigation({
    Key key,
    this.currentTab,
    this.onSelect,
  }) : super(key: key);

  final TabItem currentTab;
  final ValueChanged<TabItem> onSelect;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      items: <BottomNavigationBarItem>[
        bottomItem(
          context,
          tabItem: TabItem.home,
        ),
        bottomItem(
          context,
          tabItem: TabItem.timeline,
        ),
        bottomItem(
          context,
          tabItem: TabItem.config,
        ),
      ],
      type: BottomNavigationBarType.fixed,
      onTap: (index) {
        onSelect(TabItem.values[index]);
      },
    );
  }

  BottomNavigationBarItem bottomItem(
    BuildContext context, {
    TabItem tabItem,
  }) {
    final color = currentTab == tabItem ? Colors.blue : Colors.black26;
    return BottomNavigationBarItem(
      icon: Icon(
        tabIcon[tabItem],
        color: color,
      ),
      title: Text(
        tabTitle[tabItem],
        style: TextStyle(
          color: color,
        ),
      ),
    );
  }
}
```

## main.dart の body を変更する
IndexedStackで囲っていたWidget を先程作ったTabNavigatorとOffstageで囲みます。
もしタブ内のWidgetにアニメーションがある場合はOffstageの下にTickerModeを入れてみてください。タブが選択されていないときにアニメーションを無効にすることができます。
今回はシンプルに作るために使用しません。

```dart:main.dart
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: <Widget>[
          _buildTabItem(
            TabItem.home,
            '/home',
          ),
          _buildTabItem(
            TabItem.timeline,
            '/timeline',
          ),
          _buildTabItem(
            TabItem.config,
            '/config',
          ),
        ],
      ),
      bottomNavigationBar: BottomNavigation(
        currentTab: _currentTab,
        onSelect: onSelect,
      ),
    );
  }

  Widget _buildTabItem(
    TabItem tabItem,
    String root,
  ) {
    return Offstage(
      offstage: _currentTab != tabItem,
      child: TabNavigator(
        navigationKey: _navigatorKeys[tabItem],
        tabItem: tabItem,
        routerName: root,
      ),
    );
  }

  void onSelect(TabItem tabItem) {
    if (_currentTab == tabItem) {
      _navigatorKeys[tabItem].currentState.popUntil((route) => route.isFirst);
    } else {
      setState(() {
        _currentTab = tabItem;
      });
    }
  }
```
#Androidの戻るボタンを制御する
WillPopScope Widgetを使ってAndroidの戻るボタンのイベントを検知し処理しています
Timelineタブ、Configタブが選択されているときにAndroidの戻るボタンが押されたときにHomeタブを選択します。
ここの処理はHomeタブを選択しないで選択中のタブの初期ページに戻す等カスタマイズして使ってください。

```dart:main.dart
Future<bool> onWillPop() async {
  final isFirstRouteInCurrentTab = !await _navigatorKeys[_currentTab].currentState.maybePop();
  if (isFirstRouteInCurrentTab) {
    if (_currentTab != TabItem.home) {
      onSelect(TabItem.home);
      return false;
    }
  }
  return isFirstRouteInCurrentTab;
}

```

## おわり
FlutterのBottomNavigatonBarのNavigatorをいい感じにできたのではないでしょうか？
ちなみに下のgifが今回の完成形の動作です。

![5f98ejf.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91583/ebfd400c-26f5-7fb2-b7d3-0d2dad3d4183.gif)


今回作ったサンプルのコードをgithubで公開しています。
コード全体が見たい方は下のリンクからどうぞ
[今回作ったサンプルアプリ(github)](https://github.com/0maru/bottom_navigator_sample)

弊社ではFlutter エンジニアを募集しています。
ご興味がある方は下記のリンクからコーポレートサイトと募集要項など見てみてください。
[Flutterエンジニア(コーポレートサイト)](https://www.torico-corp.com/recruit/job/25/)