Counts the Clouds

FlutterでBottomNavigationBarを表示したまま下層ページを画面遷移する
2021.08.25

Flutter

samuel-scalzo-iqGtaQnk3VM-unsplash.jpg

以下のサンプルをデフォルトアプリの状態から手順で構築したときの記録。

元記事では関数やMapを使って重複を最適化しているが、私のような初心者にはわかりにくかったのでこの記事ではあえてベタに書いている。

DartPad

環境構築

Flutterアプリを作成。

% flutter create flutter_nested_navigation_trial
% cd flutter_nested_navigation_trial

デフォルトサンプルから使わないものを削除。

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const App(),
    );
  }
}

class App extends StatefulWidget {
  const App({Key? key}) : super(key: key);

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const <Widget>[
            Text(
              'Main',
            ),
          ],
        ),
      ),
    );
  }
}
1-start

BottomNavigationBarを追加

形だけBottomNavigationBarを追加。

  class _AppState extends State<App> {
    @override
    Widget build(BuildContext context) {
      return Scaffold(
        body: Center(
          // ...
        ),
+       bottomNavigationBar: const BottomNavigation(),
      );
    }
  }
class BottomNavigation extends StatelessWidget {
  const BottomNavigation({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      type: BottomNavigationBarType.fixed,
      items: const [
        BottomNavigationBarItem(
          icon: Icon(
            Icons.layers,
            color: Colors.red,
          ),
          label: 'red',
        ),
        // ... 以下同様
      ],
      currentIndex: 0,
      selectedItemColor: Colors.grey,
    );
  }
}
2-added-bottom-nav

設定を切り出す

色は各所で使い回すので、サンプルと同じく切り出す。

enum TabItem { red, green, blue }

const Map<TabItem, String> tabName = {
  TabItem.red: 'red',
  TabItem.green: 'green',
  TabItem.blue: 'blue',
};

const Map<TabItem, MaterialColor> activeTabColor = {
  TabItem.red: Colors.red,
  TabItem.green: Colors.green,
  TabItem.blue: Colors.blue,
};

切り出した設定を使うようにBottomNavigationBarを修正。

class BottomNavigation extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      type: BottomNavigationBarType.fixed,
      items: [
        BottomNavigationBarItem(
          icon: Icon(
            Icons.layers,
            color: activeTabColor[TabItem.red],
          ),
          label: tabName[TabItem.red],
        ),
        // ... 以下同様
      ],
      // ...
    );
  }
}

タブの状態を保持し、更新できるようにする

変数とコールバックを定義してBottomNavigationBarに渡す。

class AppState extends State<App> {
  // 現在のタブを保持する変数定義と初期値
  var _currentTab = TabItem.red;
  // タブの更新
  void _selectTab(TabItem tabItem) {
    setState(() => _currentTab = tabItem);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      bottomNavigationBar: BottomNavigation(
        currentTab: _currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }
}

class BottomNavigation extends StatelessWidget {
  const BottomNavigation({
    Key? key,
    required this.currentTab,
    required this.onSelectTab,
  }) : super(key: key);
  final TabItem currentTab;
  final void Function(TabItem) onSelectTab;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      // ...
      onTap: (index) => onSelectTab(TabItem.values[index]),
      currentIndex: currentTab.index,
      selectedItemColor: activeTabColor[currentTab]!,
    );
  }
}

これで、アクティブなタブアイテムがフォーカスされるようになった。

3-added-bottom-nav-state

ValueChanged

onSelectTabの型であるValueChangedが何なのか気になったが、コールバック関数らしい。

以下は同値らしく、JavaScriptを使っていた身としては下のほうがなじみやすいので変えてみた。

final ValueChanged<TabItem> onSelectTab;
final void Function(TabItem) onSelectTab;

ColorListPageを追加

まずは、Stackを追加して3種類のページを表示してみる。Z軸上に重ね合わせるので、最後に配置された青のリストだけが表示され、ほかのページは下に隠れてしまう。

class _AppState extends State<App> {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(children: <Widget>[
        ColorsListPage(
          color: activeTabColor[TabItem.red]!,
          title: tabName[TabItem.red]!,
          onPush: (context) {}, // あとで
        ),
        // ... 以下同様
      ]),
      // ...
    );
  }
}

ColorListPageウィジェットはサンプル通りで、今後変更することもない。

Offstageで重ね合わせを解決する

Stackで重ね合わせただけではうまく行かなかったので、Offstageを追加してページの表示・非表示を制御する。

A widget that lays the child out as if it was in the tree, but without painting anything, without making the child available for hit testing, and without taking any room in the parent.

子をツリー内にあるかのようにレイアウトするウィジェットですが、何もペイントせず、子をヒットテストに使用できるようにすることも、親にスペースをとることもありません。

公式の説明はよくわからないが、

OffstageはレイアウトWidgetであり、表示/非表示を設定したいWidgetをchildに入れる。

ということらしい。

class _AppState extends State<App> {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(children: <Widget>[
        Offstage(
          offstage: _currentTab != TabItem.red,
          child: ColorsListPage(
            color: activeTabColor[TabItem.red]!,
            title: tabName[TabItem.red]!,
            onPush: (context) {}, // あとで
          ),
        ),
        // ... 以下同様
      ]),
      bottomNavigationBar: BottomNavigation(
        currentTab: _currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }
}
5-result

Navigatorウィジェットを返すTabNavigatorウィジェットを追加。サンプルとほぼ同じ。辞書的なbuilderが最初はわかりにくかったので、switch文で書き直してみた。

class TabNavigatorRoutes {
  static const String root = '/';
  static const String detail = '/detail';
}

class TabNavigator extends StatelessWidget {
  const TabNavigator({
    Key? key,
    required this.navigatorKey,
    required this.tabItem,
  }) : super(key: key);
  final GlobalKey<NavigatorState>? navigatorKey;
  final TabItem tabItem;

  void _push(BuildContext context, {int materialIndex = 500}) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) {
          return ColorDetailPage(
            color: activeTabColor[tabItem]!,
            title: tabName[tabItem]!,
            materialIndex: materialIndex,
          );
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      initialRoute: TabNavigatorRoutes.root,
      onGenerateRoute: (routeSettings) {
        return MaterialPageRoute(
          builder: (context) {
            switch (routeSettings.name) {
              case TabNavigatorRoutes.detail:
                return ColorDetailPage(
                  color: activeTabColor[tabItem]!,
                  title: tabName[tabItem]!,
                );
              default:
                return ColorsListPage(
                  color: activeTabColor[tabItem]!,
                  title: tabName[tabItem]!,
                  onPush: (materialIndex) => _push(context, materialIndex: materialIndex),
                );
            }
          },
        );
      },
    );
  }
}

MaterialPageRoute

A modal route that replaces the entire screen with a platform-adaptive transition. For Android, the entrance transition for the page slides the route upwards and fades it in. The exit transition is the same, but in reverse.

画面全体をプラットフォームに適合したトランジションに置き換えるモーダルなルート。Androidの場合、ページに入る時は上にスライドさせてフェードインします。出る時も同じですが、方向が逆になります。

トランジションの原点になるウィジェット。NavigatorのonGenerateRouteで返却する。builderでcontextに応じて子ウィジェットを返す。

AppStateにNavigatorStateを追加して、下層ページがNavigator(前項で追加したTabNavigator)を経由するように変更。

class _AppState extends State<App> {
  var _currentTab = TabItem.red;
  final _navigatorKeys = {
    TabItem.red: GlobalKey<NavigatorState>(),
    TabItem.green: GlobalKey<NavigatorState>(),
    TabItem.blue: GlobalKey<NavigatorState>(),
  };

  void _selectTab(TabItem tabItem) {
    setState(() => _currentTab = tabItem);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(children: <Widget>[
        Offstage(
          offstage: _currentTab != TabItem.red,
          child: TabNavigator(
            navigatorKey: _navigatorKeys[TabItem.red],
            tabItem: TabItem.red,
          ),
        ),
        // ... 以下同様
      ]),
      bottomNavigationBar: BottomNavigation(
        currentTab: _currentTab,
        onSelectTab: _selectTab,
      ),
    );
  }
}

ColorDetailPageウィジェットはサンプル通りで、今後変更することもない。

6-result

GlobalKey

Global keys uniquely identify elements. Global keys provide access to other objects that are associated with those elements, such as BuildContext. For StatefulWidgets, global keys also provide access to State.

グローバルキーは一意にelementを特定する。グローバルキーは、BuildContextなど、elementに関連付けられている他のオブジェクトへのアクセスを提供します。 StatefulWidgetsの場合、グローバルキーはStateへのアクセスも提供します。

あまり深い理解はできていないが、これでStateを特定できるようだ。

基本的にはここまででサンプルと同じものが構成できた。

WillPopScopeを追加

Androidで、タブアイテムがredでないときはBackボタンがredへ戻るようにする。redにいるときはアプリがバックグラウンドになる。これがないと、ColorDetailPageからいきなりバックグラウンドになる。(手元ではHot restart時しか再現できなかったが…)

class AppState extends State<App> {
  // ...
  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        final isFirstRouteInCurrentTab =
            !await _navigatorKeys[_currentTab]!.currentState!.maybePop();
        if (isFirstRouteInCurrentTab) {
          // if not on the 'main' tab
          if (_currentTab != TabItem.red) {
            // select 'main' tab
            _selectTab(TabItem.red);
            // back button handled by app
            return false;
          }
        }
        // let system handle back button if we're on the first route
        return isFirstRouteInCurrentTab;
      },
      child: Scaffold(
        body: Stack(children: <Widget>[
          // ...
        ]),
        bottomNavigationBar: BottomNavigation(
          // ...
        ),
      ),
    );
  }
  // ...
}

WillPopScope

前の画面に戻らせたくない時に使う

画面の戻るボタンが押された際に値を返す

Future.value(true)を返してしまうと、pop関数が2度実行され、遷移元の画面もpopされてしまいます。

戻るときに何かを実行したい場合は、onWillPopコールバックで処理を書いてからfalseを返す。

戻る動作自体を止めたい場合はonWillPopコールバックでtrueを返す。