FlutterのMaterialAppでCupertinoTabViewっぽく遷移するボトムナビゲーションを作ったよ

# はじめに
この記事は「[【マイスター・ギルド】本物のAdvent Calendar 2022](https://adventar.org/calendars/7794)」4日目の記事です。

こんにちは、こんばんは、最近仕事でFlutterを触ることが多くなったhiです。
ネイティブアプリ素人なのでアワアワしながら開発をしている今日この頃、このままではいかんと思いFlutterで自作アプリを作り始めました。
Material Designで作っていく中でボトムナビゲーション欲しいなと思い始めたので、BottomNavigationBarウィジェットで実装してみました。

ボトムナビゲーション↓
![スクリーンショット 2022-11-30 18.05.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2707108/10fe5ef5-a175-dc01-e9ce-7d81aa61750f.png)

CupertinoTabScaffold + CupertinoTabView　っぽく遷移時にボトムナビゲーションが残るように実装できたので、よかったら使ってください。

作ったもの↓
![ezgif.com-gif-maker (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2707108/cf3f30d8-8fa6-ea7d-e9bc-8f1045fcfc86.gif)




ソース↓
```main.dart

import 'package:flutter/material.dart';

void main() {
  runApp( MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});
  final GlobalKey<NavigatorState> _rootNavigationKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.green,
      ),
      home: const MainPage(),
      routes: {
        "/addNewPost" : (_) => SecondPage(),
      },
    );
  }
}

//初期ページ
class MainPage extends StatefulWidget {
  const MainPage({super.key});

  @override
  State<MainPage> createState() => _MainPageState();
  }

class _MainPageState extends State<MainPage> {
  int _selectedIndex = 0;
  int _previousIndex = 0;
  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
      if(_previousIndex == _selectedIndex){
        switch (_selectedIndex) {
          case 0:
            HomeRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          case 1:
            SearchRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          case 2:
            NotificationRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          default:
        }
      }
      _previousIndex = _selectedIndex;
    });
  }

  //ScafoldのfloatingActionButtonの遷移処理
  Future<void> _showNewPostOrverlay()async{
    AppNav.toNewPostOrverlay(context);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      //
      body: BottomNavigation(index: _selectedIndex),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: '探す'),
          BottomNavigationBarItem(icon: Icon(Icons.notifications), label: 'お知らせ'),
        ],
        type: BottomNavigationBarType.fixed,
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _showNewPostOrverlay,
        tooltip: "Create New Post",
        child: Icon(Icons.add_comment),
        ),
    );
  }
}

class BottomNavigation extends StatelessWidget {
  const BottomNavigation({super.key, required this.index});

  final int index;  

  @override
  Widget build(BuildContext context) {
    //「ホーム」「探す」「お知らせ」それぞれのNavigatorをStack
    return Stack(
      fit: StackFit.expand,
      children: [
        //Offstageを使用することでBottomNavigationBarで選択したタブのNavigatorのみ表示させる
        Offstage(
          //offstageプロパティで表示の切り替え
          offstage: index != 0,
          child: Navigator(
            key: HomeRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = HomeRoutes.homeRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
        Offstage(
          offstage: index != 1,
          child: Navigator(
            key: SearchRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = SearchRoutes.searchRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
        Offstage(
          offstage: index != 2,
          child: Navigator(
            key: NotificationRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = NotificationRoutes.notificationRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
      ]
    );
  }
}

//ホームタブの選択時の初期ページ
class HomeScreen extends StatelessWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    Future<void> toHomeSecond()async{
      AppNav.toHomeSecondPage(context);
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('ホーム'),
      ),
      body: SafeArea(
        child: Center(
           child: Column(
            children: [
              Text('ホーム画面', style: TextStyle(fontSize: 32.0)),
              ElevatedButton(
                onPressed: toHomeSecond, 
                child: Text("次へ"))
            ],
           )
        ),
      ),
    );
  }
}
//探すタブ選択時の初期ページ
class SearchScreen extends StatelessWidget {
  const SearchScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    Future<void> toSearchSecond()async{
      AppNav.toSearchSecondPage(context);
    }
    return Scaffold(
      appBar: AppBar(
        title: const Text('探す'),
      ),
      body: SafeArea(
        child: Center(
           child: Column(
            children: [
              Text('探す画面', style: TextStyle(fontSize: 32.0)),
              ElevatedButton(
                onPressed: toSearchSecond, 
                child: Text("次へ"))
            ],
           )
        ),
      ),
    );
  }
}
//お知らせタブ選択時の初期ページ
class NotificationScreen extends StatelessWidget {
  const NotificationScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('お知らせ'),
      ),
      body: SafeArea(
        child: Center(
          child: Text('お知らせ画面', style: TextStyle(fontSize: 32.0)),
        ),
      ),
    );
  }
}
//遷移先のページ
class  SecondPage extends StatelessWidget {
  const SecondPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('２ページ目'),
      ),
      body: SafeArea(
        child: Center(
          child: Text('２ページ目', style: TextStyle(fontSize: 32.0)),
        ),
      ),
    );
  }
}

//ルート設定
class HomeRoutes{
    static final homeRoutes = <String,WidgetBuilder>{
    "/" : (context) => HomeScreen(),
    "/home/second" : (context) => SecondPage(),
  };
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigatorState => navigatorKey.currentState!;

}
class SearchRoutes{
    static final searchRoutes = <String,WidgetBuilder>{
    "/" : (context) => SearchScreen(),
    "/search/second" : (context) => SecondPage(),
  };
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigatorState => navigatorKey.currentState!;

}
class NotificationRoutes{
    static final notificationRoutes = <String,WidgetBuilder>{
    "/" : (context) => NotificationScreen(),
    "/notification/second" : (context) => SecondPage(),
  };
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigatorState => navigatorKey.currentState!;
}

//ナビゲーション設定
class AppNav {
  static Future<void> toHomeSecondPage(BuildContext context){
    return Navigator.of(context).pushNamed('/home/second');
  }
  static Future<void> toSearchSecondPage(BuildContext context){
    return Navigator.of(context).pushNamed('/search/second');
  }
  static Future<void> toNewPostOrverlay(BuildContext context){
    return Navigator.of(context).pushNamed('/addNewPost');
  }
}
```


# ソースの説明

 ## 1.エントリポイントの設定
Flutterはmain()から処理が始まります。
ここでは新しいプロジェクトを作成した時にデフォルトで実装されているコードを流用します。
（routesに"/addNewPost"が設定されていますが、これはScafoldのfloatingActionButton押下時の設定です。おまけで書いているので気にしないでください。）
```main.dart
import 'package:flutter/material.dart';

void main() {
  runApp( MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({super.key});
  final GlobalKey<NavigatorState> _rootNavigationKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.green,
      ),
      home: const MainPage(),
      routes: {
        "/addNewPost" : (_) => SecondPage(),
      },
    );
  }
}
```

 ## 2.メインページ
アプリを開いた時に最初に表示されるページ（メインページ）を実装します。
- _onItemTappedメソッド: 選択中のタブを再度押したときに初期ページに遷移させる処理
- _showNewPostOrverlayメソッド: ScafoldのfloatingActionButtonの遷移処理(おまけです。無視してもらっても大丈夫です。)

※詳細はソース内のコメントに記載しています。
```main.dart
//メインページ
class MainPage extends StatefulWidget {
  const MainPage({super.key});

  @override
  State<MainPage> createState() => _MainPageState();
  }

class _MainPageState extends State<MainPage> {

  //選択中のタブを再度押したときに初期ページに遷移させる処理
  int _selectedIndex = 0; //BottomNavigationBarで選択したタブのインデックスを保持
  int _previousIndex = 0; //前回押下したタブのインデックスを保持
  void _onItemTapped(int index) {
    setState(() {
      //_selectedIndex()を参照して選択中のタブが再度押下されたかを判定
      _selectedIndex = index;
      if(_previousIndex == _selectedIndex){
        switch (_selectedIndex) {
          case 0:
            HomeRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          case 1:
            SearchRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          case 2:
            NotificationRoutes.navigatorKey.currentState!.popUntil(ModalRoute.withName('/'));
            break;
          default:
        }
      }
      _previousIndex = _selectedIndex;
    });
  }

  //ScafoldのfloatingActionButtonの遷移処理（おまけ）
  Future<void> _showNewPostOrverlay()async{
    AppNav.toNewPostOrverlay(context);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      //bodyに自作したBottomNavigationウィジェットをセット（説明は後々します。）
      body: BottomNavigation(index: _selectedIndex),
      //bottomNavigationBarにBottomNavigationBarウィジェットをセット
      //（barのタブを選択することによってBottomNavigationウィジェットの状態を切り替えます。）
      bottomNavigationBar: BottomNavigationBar(
        //currentIndexにセットした変数（ここでは_selectedIndex)に選択したタブのインデックスが保持されます。
        currentIndex: _selectedIndex,
        onTap: _onItemTapped,
        //bottomNavigationBarに並ぶタブのiconを設定
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: '探す'),
          BottomNavigationBarItem(icon: Icon(Icons.notifications), label: 'お知らせ'),
        ],
        type: BottomNavigationBarType.fixed,
      ),
      //フローティングボタンの設定です。（おまけなので気にしないでください）
      floatingActionButton: FloatingActionButton(
        onPressed: _showNewPostOrverlay,
        tooltip: "Create New Post",
        child: Icon(Icons.add_comment),
        ),
    );
  }
}
```

 ## 3.ボトムナビゲーションのタブ選択で画面を切り替える
ボトムナビゲーションのタブ選択を切り替えた際に以下の動作で遷移が行われるように実装します。
- タブ内で遷移した時にボトムナビゲーションを表示させたまま遷移
- 他のタブに切り替えた際、元いたタブの状態が維持される。(タブの切り替え時に毎回初期状態に戻らない)

実装としては「ホーム」「探す」「お知らせ」それぞれのNavigatorを用意し、それをStackさせ、Offstageで表示非表示を切り替えることでタブ遷移しているように見せかけます。
↓みたいなイメージです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2707108/af086851-9ab6-cb8e-43a7-eceabe388538.png)
ただ、この場合Navigatorが複数存在することになるので遷移が少し複雑になります。
↓Navigatorの大まかなツリー構造
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2707108/bbb07be0-b1ec-8970-5f2a-4c0ac014a910.png)


```main.dart
class BottomNavigation extends StatelessWidget {
  const BottomNavigation({super.key, required this.index});

  final int index;  

  @override
  Widget build(BuildContext context) {
    //「ホーム」「探す」「お知らせ」それぞれのNavigatorをStack
    return Stack(
      fit: StackFit.expand,
      children: [
        //Offstageを使用することでボトムナビゲーションで選択したタブのNavigatorのみ表示させる
        Offstage(
          //offstageプロパティで表示の切り替え
          offstage: index != 0,
          child: Navigator(
            key: HomeRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = HomeRoutes.homeRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
        Offstage(
          offstage: index != 1,
          child: Navigator(
            key: SearchRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = SearchRoutes.searchRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
        Offstage(
          offstage: index != 2,
          child: Navigator(
            key: NotificationRoutes.navigatorKey,
            onGenerateRoute: (settings) {
              var builder = NotificationRoutes.notificationRoutes[settings.name]!;
              return MaterialPageRoute(builder: builder,settings: settings);
            },
          ) ,
        ),
      ]
    );
  }
}
```


 ## ４.ボトムナビゲーションの各タブの初期ページ
タブそれぞれの初期ページを実装
※説明することがないので割愛します。

 ## 5.ルート設定
「ホーム」「探す」「お知らせ」それぞれにNavigatorをセットしたのでそれぞれにルートを設定します。
```main.dart
//ルート設定
//「ホーム」用のルート
class HomeRoutes{
    //ルートを設定
    static final homeRoutes = <String,WidgetBuilder>{
    "/" : (context) => HomeScreen(),
    "/home/second" : (context) => SecondPage(),
  };
  //Navigatorに渡すグローバルキー
  //（MaterialAppが持つNavigatorとそれぞれのタブが持つNavigatorがあるため、ブローバルキーを設定する必要があります。）
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  //グローバルキーからNavigatorのステートにアクセスして画面遷移用のメソッドを使用できるようにします。
  static NavigatorState get navigatorState => navigatorKey.currentState!;

}
//「探す」用のルート
class SearchRoutes{
    static final searchRoutes = <String,WidgetBuilder>{
    "/" : (context) => SearchScreen(),
    "/search/second" : (context) => SecondPage(),
  };
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigatorState => navigatorKey.currentState!;

}
//「お知らせ」用のルート
class NotificationRoutes{
    static final notificationRoutes = <String,WidgetBuilder>{
    "/" : (context) => NotificationScreen(),
    "/notification/second" : (context) => SecondPage(),
  };
  static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
  static NavigatorState get navigatorState => navigatorKey.currentState!;
}

```

 ## 6.ナビゲーション設定
画面遷移機能をそれぞれのページから分離したものになります。
（main.dartからファイルを分けることを想定して分離させています。）
```main.dart
//ナビゲーション設定
class AppNav {
  static Future<void> toHomeSecondPage(BuildContext context){
    return Navigator.of(context).pushNamed('/home/second');
  }
  static Future<void> toSearchSecondPage(BuildContext context){
    return Navigator.of(context).pushNamed('/search/second');
  }
  static Future<void> toNewPostOrverlay(BuildContext context){
    return Navigator.of(context).pushNamed('/addNewPost');
  }
}
```

ソースの説明は以上です。

 ## 7.最後に
FlutterはReactっぽい感じで実装できるように感じたので、フロントエンドの人はぜひ触ってみてほしです。
Dartは静的型付け言語なので他の静的型付け言語の経験がある人も馴染みやすいんじゃないかなと思います。


 ## 参考文献
https://dev.classmethod.jp/articles/flutter-bottom-navigation/